Spring Security's Session Auth Flow: AuthenticationManager, Providers, and Cookies
Tracing a session-based login request step by step through Spring Security — from the login endpoint to the session cookie that keeps you logged in.
Why I Went Looking Past the Filter Chain
Knowing the filter chain blocks requests was step one. Step two was realizing "authentication" isn't one thing Spring Security does — it's a request getting handed off through a small chain of specific objects, one at a time, until something finally says yes or no. Session-based login is the only flow I've actually used so far, so this is that flow specifically: form login, backed by a session cookie, not JWT or OAuth2. Once I traced that hand-off in order, login stopped feeling like a black box.
The Full Flow, Step by Step
Here's what actually happens between a user hitting submit on a login form and being authenticated.
1. The request hits UsernamePasswordAuthenticationFilter
This filter sits in the SecurityFilterChain, listening for POSTs to the login URL (/login by default). It doesn't check anything itself — its only job is to grab the username and password out of the request and wrap them into a token:
Authentication authRequest =
new UsernamePasswordAuthenticationToken(username, password);
At this point that token is unauthenticated — just a labeled container holding raw credentials. Nothing has verified anything yet.
2. The filter hands the token to AuthenticationManager
Authentication result = authenticationManager.authenticate(authRequest);
AuthenticationManager is an interface with one method. The filter doesn't know or care how authentication actually happens — it just hands off the token and waits for either an authenticated result or an exception.
3. ProviderManager (the default AuthenticationManager) loops through its providers
This is the part that's easy to miss: AuthenticationManager itself doesn't check passwords. Its default implementation, ProviderManager, holds a list of AuthenticationProviders and asks each one, in order:
for (AuthenticationProvider provider : providers) {
if (!provider.supports(authRequest.getClass())) {
continue; // not this provider's job, try the next one
}
return provider.authenticate(authRequest);
}
It keeps asking supports() until it finds a provider willing to handle this specific token type. This is why username/password login and OAuth2 login can coexist in the same app — they're just different providers sitting in the same list, each one only stepping up for the token type it knows how to handle.
4. DaoAuthenticationProvider does the actual checking
For a UsernamePasswordAuthenticationToken, the provider that says yes is usually DaoAuthenticationProvider. It doesn't talk to a database directly — it delegates to two collaborators:
UserDetails user = userDetailsService.loadUserByUsername(username);
if (!passwordEncoder.matches(rawPassword, user.getPassword())) {
throw new BadCredentialsException("Bad credentials");
}
UserDetailsService— loads the user by username. This is the one piece you almost always write yourself, pulling from your own database.PasswordEncoder— checks the submitted raw password against the stored hash.
If either step fails, an exception is thrown and it propagates all the way back up through ProviderManager and the filter — login fails here.
5. On success, a new, authenticated token is built
If the password matches, the provider doesn't just return "true" — it builds a new UsernamePasswordAuthenticationToken, this time holding the user's granted authorities instead of the raw password:
return new UsernamePasswordAuthenticationToken(user, null, user.getAuthorities());
Same class as step 1, but a completely different state — this one has isAuthenticated() returning true.
6. That authenticated token is stored in SecurityContextHolder
Back in the filter, once authenticationManager.authenticate() returns successfully:
SecurityContextHolder.getContext().setAuthentication(result);
This is the moment the user is actually "logged in" — but only for this one request, since SecurityContextHolder is backed by a ThreadLocal. On its own, this doesn't survive past the response. Something has to persist it.
7. The SecurityContext gets saved into the session, and a cookie is sent back
This is where it becomes an actual session auth flow instead of a one-off check. A SecurityContextRepository saves the SecurityContext into the HttpSession:
securityContextRepository.saveContext(context, request, response);
Creating (or reusing) an HttpSession is what triggers the server to send back a Set-Cookie header, holding a session ID:
Set-Cookie: JSESSIONID=5F3A1C9E2B7D...; Path=/; HttpOnly
JSESSIONIDis just a random ID pointing at server-side session storage — the cookie itself holds no user data, no username, nothing readable. All the actualAuthenticationdata stays server-side.HttpOnlymeans JavaScript can't read this cookie, which blocks a whole class of XSS-based cookie theft.- The browser stores this cookie and automatically resends it on every request to the same origin, no extra code needed on the frontend.
8. On every later request, the cookie comes back and gets exchanged for the SecurityContext
The browser attaches Cookie: JSESSIONID=5F3A1C9E2B7D... automatically. Early in the filter chain, SecurityContextHolderFilter uses that ID to look up the matching HttpSession, pulls the saved SecurityContext back out, and repopulates SecurityContextHolder — before AuthorizationFilter ever runs.
9. AuthorizationFilter reads the now-repopulated context
Whatever runs after this filter — including AuthorizationFilter, deciding whether this user can access this URL — reads from SecurityContextHolder and sees an authenticated user instead of an empty context, all without the user having to log in again.
Why It's Split Into So Many Small Pieces
At first this felt like a lot of ceremony for "check username and password." But each piece only does one job:
- The filter only extracts credentials from the request.
AuthenticationManageronly orchestrates — it doesn't know how to check anything.AuthenticationProvideris the only layer that knows the actual rules for one type of login.UserDetailsServiceandPasswordEncoderare the only pieces that touch your actual data.SecurityContextRepositoryis the only piece that knows how to persist the result across requests.
That separation is exactly why adding a second login method (say, OAuth2) later doesn't mean rewriting this whole flow — it means adding one more provider to the list in step 3, and everything else stays untouched. It's also why swapping the persistence mechanism — say, moving from HttpSession to a JWT stored in the cookie or header instead — only touches steps 7 and 8, not the authentication logic in steps 1 through 6.
Where My Understanding Still Breaks Down
I've only traced the success path in real detail — I still want to walk through exactly which exception types come out of each failure point (bad username vs bad password vs locked account) and how ExceptionTranslationFilter turns each one into a specific HTTP response. I also haven't touched what happens when the session expires or the cookie is missing/tampered with — that's a whole separate failure path I haven't traced yet.
What This Taught Me
- Authentication is a hand-off chain, not a single check: filter -> manager -> provider -> your own
UserDetailsService, and back up again. AuthenticationManagerdecides that you're authenticated;AuthenticationProviderdecides how. You almost never write the first, and usually only customize the second through aUserDetailsService.- The same
UsernamePasswordAuthenticationTokenclass represents both "not yet checked" and "checked and approved" — the state, not the type, is what changes between step 1 and step 5. - The whole "stay logged in" experience is just a session ID in a cookie, exchanged for a stored
SecurityContexton every request — the cookie itself carries no identity data at all.