Building a Login/Authentication Flow
Build a database-backed authentication flow with UserDetailsService, a registration endpoint, and a login endpoint that issues a token.
Introduction
The previous lesson covered the basics of locking down endpoints with Spring Security. This lesson builds a complete, database-backed authentication flow on top of that foundation: users register with a username and password, log in to receive a token, and use that token on subsequent requests. This is the pattern behind most real-world Spring Boot APIs.
- The pieces that make up a login/authentication flow.
- How to model a User entity for authentication.
- How to implement a custom UserDetailsService backed by the database.
- How to build registration and login endpoints.
- How a token is used to protect subsequent requests.
The Pieces of an Auth Flow
A database-backed authentication flow has a handful of moving parts that fit together in a predictable way.
| Piece | Responsibility |
|---|---|
| User entity | Stores username and a hashed password |
| UserDetailsService | Loads a User from the database for Spring Security to authenticate against |
| Registration endpoint | Creates a new user with an encoded password |
| Login endpoint | Verifies credentials and issues a token |
| SecurityFilterChain | Validates the token on every subsequent request |
The User Entity
Implementing UserDetails directly on the entity is a common, convenient approach - it lets Spring Security work with your own User class with no separate adapter type.
@Entity@Table(name = "app_user")public class User implements UserDetails {
@Id @GeneratedValue(strategy = GenerationType.IDENTITY) private Long id;
@Column(unique = true, nullable = false) private String username;
@Column(nullable = false) private String password;
@Override public Collection<? extends GrantedAuthority> getAuthorities() { return List.of(new SimpleGrantedAuthority("ROLE_USER")); }
@Override public String getUsername() { return username; }
@Override public String getPassword() { return password; }
@Override public boolean isAccountNonExpired() { return true; }
@Override public boolean isAccountNonLocked() { return true; }
@Override public boolean isCredentialsNonExpired() { return true; }
@Override public boolean isEnabled() { return true; }
// getters and setters for id, username, password}Custom UserDetailsService
UserDetailsService has a single method, loadUserByUsername, that Spring Security calls during authentication. Implementing it against your repository is all that is needed to authenticate against your own database.
@Servicepublic class AppUserDetailsService implements UserDetailsService {
private final UserRepository userRepository;
public AppUserDetailsService(UserRepository userRepository) { this.userRepository = userRepository; }
@Override public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException { return userRepository.findByUsername(username) .orElseThrow(() -> new UsernameNotFoundException("No user: " + username)); }}Registration Endpoint
Registration creates a new User row with the password encoded, never stored in plain text.
public record RegisterRequest(@NotBlank String username, @NotBlank @Size(min = 8) String password) {}
@RestController@RequestMapping("/api/auth")public class AuthController {
private final UserRepository userRepository; private final PasswordEncoder passwordEncoder;
public AuthController(UserRepository userRepository, PasswordEncoder passwordEncoder) { this.userRepository = userRepository; this.passwordEncoder = passwordEncoder; }
@PostMapping("/register") public ResponseEntity<Void> register(@Valid @RequestBody RegisterRequest request) { User user = new User(); user.setUsername(request.username()); user.setPassword(passwordEncoder.encode(request.password())); userRepository.save(user); return ResponseEntity.status(HttpStatus.CREATED).build(); }}Click Run to see what this code prints.
Click Run to see what this code prints.
Login Endpoint
The login endpoint authenticates the submitted credentials with AuthenticationManager, then issues a token. The example below issues a JWT signed with a server-side secret; a full JWT implementation is a topic of its own, so this shows the shape of the flow.
public record LoginRequest(String username, String password) {}public record LoginResponse(String token) {}
@RestController@RequestMapping("/api/auth")public class LoginController {
private final AuthenticationManager authenticationManager; private final JwtService jwtService;
public LoginController(AuthenticationManager authenticationManager, JwtService jwtService) { this.authenticationManager = authenticationManager; this.jwtService = jwtService; }
@PostMapping("/login") public LoginResponse login(@RequestBody LoginRequest request) { Authentication authentication = authenticationManager.authenticate( new UsernamePasswordAuthenticationToken(request.username(), request.password()) ); String token = jwtService.generateToken(authentication.getName()); return new LoginResponse(token); }}Click Run to see what this code prints.
Protecting Endpoints
A JwtAuthenticationFilter (registered before UsernamePasswordAuthenticationFilter in the SecurityFilterChain) reads the Authorization header on every request, validates the token, and sets the authenticated user on the SecurityContext so downstream controllers see a properly authenticated request.
Click Run to see what this code prints.
Common Mistakes
- Comparing raw passwords with equals() instead of letting PasswordEncoder and AuthenticationManager do the comparison.
- Returning "user not found" versus "wrong password" as different error messages, which leaks which usernames exist.
- Storing a JWT signing secret in source control instead of an environment variable or secret manager.
- Forgetting to set a token expiration, leaving tokens valid indefinitely.
- Not validating registration input, allowing empty usernames or trivially short passwords.
Best Practices
- Use one generic error message for failed login, regardless of whether the username or password was wrong.
- Set a reasonable token expiration and support refreshing it rather than issuing tokens that never expire.
- Validate registration input with Bean Validation, as covered in the validation lesson.
- Keep signing secrets and other credentials in environment-specific configuration, never hard-coded.
- Log authentication failures for security monitoring, without logging the submitted password.
Frequently Asked Questions
No. This lesson shows the underlying mechanics, but for production use many teams rely on Spring Security's OAuth2 Resource Server support or a managed identity provider (Auth0, Okta, Keycloak) instead of hand-rolling token issuance.
That depends on the client type. Browser-based single-page apps commonly face tradeoffs between local storage (vulnerable to XSS) and httpOnly cookies (which reintroduce CSRF considerations) - a topic worth researching for your specific frontend.
It delegates to one or more AuthenticationProvider beans - by default, one that uses your UserDetailsService and PasswordEncoder - to verify the submitted username and password before returning an authenticated Authentication object.
Key Takeaways
- A database-backed auth flow needs a User entity, a UserDetailsService, and encoded password storage.
- Registration encodes the password before saving; login verifies it through AuthenticationManager.
- A successful login issues a token that the client sends on subsequent requests.
- A filter validates that token on every request and populates the SecurityContext.
- Use one generic failure message for login errors to avoid leaking which usernames exist.
Summary
A complete authentication flow combines a User entity, a custom UserDetailsService, encoded password storage, and a token issued at login that protects every subsequent request. With this foundation in place, the next lesson looks at the other direction - calling external APIs from within your own Spring Boot application.