back to blog
Aug 30, 2026

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.

JavaSpringSpring Security

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
  • JSESSIONID is just a random ID pointing at server-side session storage — the cookie itself holds no user data, no username, nothing readable. All the actual Authentication data stays server-side.
  • HttpOnly means 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.
  • AuthenticationManager only orchestrates — it doesn't know how to check anything.
  • AuthenticationProvider is the only layer that knows the actual rules for one type of login.
  • UserDetailsService and PasswordEncoder are the only pieces that touch your actual data.
  • SecurityContextRepository is 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.
  • AuthenticationManager decides that you're authenticated; AuthenticationProvider decides how. You almost never write the first, and usually only customize the second through a UserDetailsService.
  • The same UsernamePasswordAuthenticationToken class 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 SecurityContext on every request — the cookie itself carries no identity data at all.