Authentication vs authorization precisely, the AuthenticationManager/ProviderManager delegate chain, UserDetailsService, BCrypt's cost factor tradeoff, and where the authenticated principal actually lives.
Published September 23, 2026
Spring Security Overview covers the filter chain a request passes through. This lesson covers what actually happens inside the authentication step of that chain.
Authentication answers "who are you" — verifying identity, typically via a login step. Authorization answers "what are you allowed to do" — permission checks performed after identity is established. A request can be fully authenticated (Spring knows exactly who's making it) and still fail authorization (that identified user isn't allowed to perform this specific action) — these are two genuinely separate checks, not two names for the same thing, and conflating them is a common source of imprecise reasoning about security bugs ("why did this 403" vs "why did this 401" are different questions with different root causes).
interface AuthenticationManager {
Authentication authenticate(Authentication authentication) throws AuthenticationException;
}
Spring Security's default AuthenticationManager implementation, ProviderManager, doesn't do the actual credential checking itself — it delegates to a list of AuthenticationProviders, trying each in turn until one successfully authenticates (or all fail). This is Chain of Responsibility applied to authentication: a DaoAuthenticationProvider might handle username/password login, while a separate provider handles JWT-based authentication — ProviderManager doesn't need to know the specifics of either, it just tries each provider until one accepts the request or all reject it.
interface UserDetailsService {
UserDetails loadUserByUsername(String username) throws UsernameNotFoundException;
}
@Service
class MongoUserDetailsService implements UserDetailsService {
private final UserRepository userRepository;
public UserDetails loadUserByUsername(String username) {
User user = userRepository.findByEmail(username)
.orElseThrow(() -> new UsernameNotFoundException("No user: " + username));
return user; // User implements UserDetails — see Role-Based Access Control
}
}
This is the seam where Spring Security connects to your actual user store — DaoAuthenticationProvider calls loadUserByUsername(), gets back a UserDetails (containing the stored password hash and granted authorities), and compares the submitted credentials against it. Implementing this one method is usually the entire integration point needed to wire Spring Security to a custom user database.
See Password Encoding for the mechanics of encoding itself — worth restating the core tradeoff here: BCrypt's cost factor (work factor, typically 10-12 by default) directly controls how many hashing rounds it performs, which directly controls how long each hash/verify operation takes. A higher cost factor makes brute-force password-guessing attacks proportionally more expensive for an attacker — but it also makes every legitimate login slightly slower and more CPU-intensive on your own servers. This is a deliberate, tunable security-vs-performance tradeoff, not a fixed constant — raising it as hardware gets faster over time is a standard, ongoing security practice (what was an expensive cost factor a decade ago is cheap to brute-force today).
public interface Authentication {
Object getPrincipal(); // typically the UserDetails (or username)
Object getCredentials(); // typically the password — usually cleared after authentication for security
Collection<? extends GrantedAuthority> getAuthorities(); // roles/permissions, populated post-auth
boolean isAuthenticated();
}
Before authentication succeeds, an Authentication object typically holds just the submitted username/password (unauthenticated). After a provider successfully authenticates it, a new, fully-populated Authentication object is produced — principal set to the loaded UserDetails, authorities populated from that user's granted roles, isAuthenticated() now true. This populated object is what gets stored for the rest of the request's lifetime.
Authentication auth = SecurityContextHolder.getContext().getAuthentication();
String currentUsername = auth.getName();
SecurityContextHolder stores the current SecurityContext (which wraps the Authentication object) in a ThreadLocal by default — exactly the same mechanism covered in Memory Leaks in Java, and subject to the exact same pooled-thread caveat: in a thread-pool-based server, this ThreadLocal must be cleared between requests (Spring Security's own filter chain handles this automatically for standard request processing), or a later request served on a reused thread could see a previous request's authenticated user. This is precisely why SecurityContextHolder access only makes sense during request processing on the thread the filter chain populated — accessing it from a separate async thread pool requires explicitly propagating the context, since the new thread's ThreadLocal starts empty.
Session-based: after login, the server creates a session (stored server-side, referenced by a cookie) — every subsequent request looks up that session to re-establish Authentication. Requires server-side session storage (or a shared session store across instances), but revocation is trivial (just delete the session). Stateless (JWT-based, see JWT Authentication): the client holds a self-contained, signed token carrying all necessary claims — no server-side session storage needed, which scales horizontally without sticky sessions or a shared session store, but revocation before natural expiry is genuinely harder (see JWT-Based Stateless Auth's coverage of the revocation problem and its mitigations).
Q: If ProviderManager tries multiple AuthenticationProviders, how does it know which one 'owns' a given authentication attempt?
A: Each AuthenticationProvider implements supports(Class<?> authentication), declaring which Authentication subtype it knows how to handle — ProviderManager only offers an authentication attempt to providers that claim to support that specific type, rather than trying every provider blindly against every request type.
Q: Why does Authentication.getCredentials() typically get cleared after successful authentication? A: Holding a plaintext password in memory for the lifetime of the request (or longer, if the Authentication object is cached or logged) is an unnecessary security exposure once it's no longer needed for the actual authentication check — clearing it is a defensive practice, reducing the window in which a memory dump, log statement, or debugging session could expose a raw credential.
Q: Is SecurityContextHolder's ThreadLocal storage a problem for virtual threads (see Virtual Threads)? A: Not inherently — each virtual thread gets its own ThreadLocal storage just like a platform thread, and Spring Security's per-request filter chain populates and clears it per request regardless of whether that request runs on a platform or virtual thread. The concern would only arise with manual thread hand-off patterns that don't go through Spring's own request-scoped context propagation.
Q: Could authentication state be stored somewhere other than SecurityContextHolder's ThreadLocal?
A: Yes — SecurityContextHolder supports pluggable storage strategies, including one that stores context directly on a thread-inheritable basis for child threads, relevant when authenticated work spawns additional threads that need the same security context propagated to them explicitly.