The cast of characters
Authentication in Spring Security looks tangled until you learn the four roles, then it's a clean assembly
line. A filter captures credentials, an AuthenticationManager coordinates, an AuthenticationProvider
verifies, and a UserDetailsService loads the user:
filter → AuthenticationManager → AuthenticationProvider
→ UserDetailsService.loadUserByUsername()
→ PasswordEncoder.matches()
→ authenticated Authentication → SecurityContext
The output is an authenticated Authentication placed in the SecurityContext, available for the rest
of the request. Knowing each station's job is what lets you answer "how does login actually work" without
hand-waving.
UserDetailsService: bridging your database
The default in-memory user is scaffolding. Real apps implement UserDetailsService — a single method that
loads a user by username — to bridge their own table to Spring Security:
@Service
class JpaUserDetailsService implements UserDetailsService {
private final UserRepository users;
public UserDetails loadUserByUsername(String username) {
AppUser u = users.findByUsername(username)
.orElseThrow(() -> new UsernameNotFoundException(username));
return User.withUsername(u.getUsername())
.password(u.getPasswordHash()) // the STORED hash
.authorities(u.getRoles().toArray(String[]::new))
.build();
}
}
Notice you return the stored hash, not a plaintext password. You never decode anything — Spring compares
the submitted password against this hash with the PasswordEncoder. Defining this bean replaces the
generated console user entirely.
UserDetails: the contract
Spring only ever talks to the UserDetails interface, which decouples it from your entity. It exposes the
username, the hashed password, the authorities, and four account-status flags:
public interface UserDetails {
String getUsername();
String getPassword();
Collection<? extends GrantedAuthority> getAuthorities();
boolean isEnabled();
boolean isAccountNonLocked();
// isAccountNonExpired, isCredentialsNonExpired
}
You can return the built-in User or make your own entity implement the interface. The status flags are
quietly powerful — they let Spring reject disabled or locked accounts after the password matches, with
no extra controller logic.
Manager and providers
The AuthenticationManager is the entry point; the standard ProviderManager holds a list of
AuthenticationProviders and tries each until one handles the token. For username/password, that's
DaoAuthenticationProvider:
@Bean
AuthenticationManager authManager(UserDetailsService uds, PasswordEncoder pe) {
var provider = new DaoAuthenticationProvider();
provider.setUserDetailsService(uds);
provider.setPasswordEncoder(pe);
return new ProviderManager(provider);
}
DaoAuthenticationProvider does the real work: load the user, run passwordEncoder.matches(), check the
status flags, and produce an authenticated token. In Boot you often don't build it by hand — defining a
UserDetailsService and PasswordEncoder bean is enough for auto-configuration to wire one up. The
multi-provider design is how one manager can support password, LDAP, and token auth together.
Form login vs HTTP Basic
Two built-in mechanisms cover most cases. Form login shows an HTML page, posts credentials, and stores auth in a session — for browser apps. HTTP Basic sends the credentials in a header on every request — for APIs and tooling:
http.formLogin(withDefaults()); // login page + session
// or
http.httpBasic(withDefaults()); // Authorization: Basic ... on every request
Basic must run over HTTPS — base64 is encoding, not encryption — and has no real logout. Form login gives you a UX and a session lifecycle. APIs that want neither usually move to a token scheme.
Reading the current user
In a controller, don't reach into SecurityContextHolder. Inject the principal:
@GetMapping("/me")
String me(@AuthenticationPrincipal UserDetails user) {
return user.getUsername();
}
@AuthenticationPrincipal can even bind your custom principal type directly, and an Authentication
parameter gives you the authorities. Spring resolves these per request from the context, so they always
reflect the current user.
A custom AuthenticationProvider
When credentials don't fit username/password — an API key, an external code — implement
AuthenticationProvider. authenticate() verifies; supports() declares the token type:
@Component
class ApiKeyAuthProvider implements AuthenticationProvider {
public Authentication authenticate(Authentication a) {
String key = (String) a.getCredentials();
if (!keyService.isValid(key)) throw new BadCredentialsException("bad key");
return new ApiKeyAuthenticationToken(key, keyService.authorities(key));
}
public boolean supports(Class<?> type) {
return ApiKeyAuthenticationToken.class.isAssignableFrom(type);
}
}
Return an authenticated token on success, throw an AuthenticationException on failure, and register it
with the manager.
The deliberate vagueness of "Bad credentials"
A detail that confuses people: even an unknown username surfaces as BadCredentialsException, not
"user not found." That's on purpose — it prevents username enumeration. Spring also runs the password
check against a dummy hash for unknown users so the response timing is similar, defeating timing attacks.
Keep this default on in production; a helpful "no such user" message is a gift to attackers.
Account status, events, and logout
Because UserDetails carries status flags, lockout and deactivation are declarative — a correct password
still can't log in a disabled account. Login outcomes are also published as events you can audit without
touching the auth flow:
@EventListener
void onFailure(AbstractAuthenticationFailureEvent e) {
log.warn("Login failed for {}", e.getAuthentication().getName());
}
For session apps, /logout invalidates the session, clears the context, and drops the cookie. For
stateless token apps there's nothing server-side to invalidate — "logout" means the client discards the
token, optionally backed by a server-side deny-list until it expires.
Recap
Authentication flows filter → manager → provider → user store, producing an authenticated token in the
SecurityContext. Implement UserDetailsService to load users (returning the stored hash) and let
DaoAuthenticationProvider + PasswordEncoder verify them. Pick form login for browsers and HTTP
Basic for APIs; read the user via @AuthenticationPrincipal; add a custom AuthenticationProvider for
non-standard credentials; lean on the UserDetails status flags and authentication events for lockout
and auditing; and remember that "Bad credentials" is intentionally vague to stop username enumeration.