Authentication Internals: AuthenticationManager, AuthenticationProvider Resolution, and the Token Type Dispatch
The Contract
Spring Security’s entire authentication subsystem routes through one interface:
public interface AuthenticationManager {
Authentication authenticate(Authentication authentication)
throws AuthenticationException;
}
One method. One input. One output. The caller hands in an unauthenticated Authentication token. The manager hands back an authenticated one, or throws.
This is the facade. The caller never knows which provider handled the request, which database was queried, or which credential format was validated. That is the point.
In the SaaS backend, the servlet filter chain calls AuthenticationManager.authenticate() with a UsernamePasswordAuthenticationToken when an admin logs in through the web form. It calls the same method with a BearerTokenAuthenticationToken when a tenant’s API client sends a JWT. The same interface. Different token types. Different providers handle each.
ProviderManager: The Dispatcher
The framework ships exactly one production implementation of AuthenticationManager: ProviderManager.
ProviderManager holds a List<AuthenticationProvider>. When authenticate() is called, it iterates the list. For each provider, it calls provider.supports(toTest) where toTest is the class of the incoming Authentication token. The first provider that returns true gets the authenticate() call.
public class ProviderManager implements AuthenticationManager {
private List<AuthenticationProvider> providers;
private AuthenticationManager parent;
public Authentication authenticate(Authentication authentication)
throws AuthenticationException {
Class<? extends Authentication> toTest = authentication.getClass();
AuthenticationException lastException = null;
Authentication result = null;
for (AuthenticationProvider provider : providers) {
if (!provider.supports(toTest)) {
continue;
}
try {
result = provider.authenticate(authentication);
if (result != null) {
copyDetails(authentication, result);
break;
}
} catch (AuthenticationException e) {
lastException = e;
}
}
if (result == null && this.parent != null) {
result = parent.authenticate(authentication);
}
if (result != null) {
eraseCredentials(result);
return result;
}
throw lastException != null ? lastException
: new ProviderNotFoundException("No AuthenticationProvider found for "
+ toTest.getName());
}
}
This is type-based dispatch. The token class is the routing key. UsernamePasswordAuthenticationToken routes to DaoAuthenticationProvider. BearerTokenAuthenticationToken routes to JwtAuthenticationProvider. No conditional chains. No string matching. Class identity determines the path.
The supports() Contract
Each AuthenticationProvider declares which token types it handles:
public interface AuthenticationProvider {
Authentication authenticate(Authentication authentication)
throws AuthenticationException;
boolean supports(Class<?> authentication);
}
DaoAuthenticationProvider returns true for UsernamePasswordAuthenticationToken.class. JwtAuthenticationProvider returns true for BearerTokenAuthenticationToken.class. This is the mechanism that makes the dispatch work.
The supports() method receives a Class<?>, not an instance. ProviderManager calls it with authentication.getClass() before ever passing the token to authenticate(). This means supports() cannot inspect the token’s contents. It can only inspect the type hierarchy.
The standard implementation uses isAssignableFrom:
// Inside DaoAuthenticationProvider (via AbstractUserDetailsAuthenticationProvider)
@Override
public boolean supports(Class<?> authentication) {
return UsernamePasswordAuthenticationToken.class
.isAssignableFrom(authentication);
}
isAssignableFrom handles subclasses. If you extend UsernamePasswordAuthenticationToken with a TenantUsernamePasswordAuthenticationToken, DaoAuthenticationProvider will still match it.
The SaaS Backend Configuration
The multi-tenant SaaS backend needs two authentication paths. Admin users log in with username and password through a form. Tenant API clients authenticate with JWT bearer tokens.
@Configuration
@EnableWebSecurity
public class SecurityConfig {
@Bean
public AuthenticationManager authenticationManager(
DaoAuthenticationProvider daoProvider,
JwtAuthenticationProvider jwtProvider) {
return new ProviderManager(List.of(daoProvider, jwtProvider));
}
@Bean
public DaoAuthenticationProvider daoAuthenticationProvider(
TenantUserDetailsService userDetailsService,
PasswordEncoder passwordEncoder) {
DaoAuthenticationProvider provider = new DaoAuthenticationProvider();
provider.setUserDetailsService(userDetailsService);
provider.setPasswordEncoder(passwordEncoder);
return provider;
}
@Bean
public JwtAuthenticationProvider jwtAuthenticationProvider(
JwtDecoder jwtDecoder) {
return new JwtAuthenticationProvider(jwtDecoder);
}
@Bean
public PasswordEncoder passwordEncoder() {
return PasswordEncoderFactories.createDelegatingPasswordEncoder();
}
}
When a form login filter produces a UsernamePasswordAuthenticationToken, ProviderManager iterates. DaoAuthenticationProvider.supports(UsernamePasswordAuthenticationToken.class) returns true. It handles the request. JwtAuthenticationProvider never sees it.
When the bearer token filter produces a BearerTokenAuthenticationToken, ProviderManager iterates. DaoAuthenticationProvider.supports(BearerTokenAuthenticationToken.class) returns false. Skip. JwtAuthenticationProvider.supports(BearerTokenAuthenticationToken.class) returns true. It handles the request.
The order in the list matters only when multiple providers support the same token type. The first match wins.
The Token Objects
An Authentication token carries the credentials before authentication and the granted authorities after authentication.
Before authentication:
// Created by UsernamePasswordAuthenticationFilter
var token = UsernamePasswordAuthenticationToken.unauthenticated(
"[email protected]", // principal
"rawPassword123" // credentials
);
After authentication:
// Returned by DaoAuthenticationProvider
var authenticated = UsernamePasswordAuthenticationToken.authenticated(
userDetails, // principal (now a UserDetails object)
null, // credentials (erased)
userDetails.getAuthorities()
);
The isAuthenticated() flag flips from false to true. The credentials field gets erased (set to null) after ProviderManager receives the result. This is a deliberate security measure: credentials should not persist in memory longer than necessary.
Debuggable Demonstration
Set a breakpoint in ProviderManager.authenticate() at the provider.supports(toTest) line. Send a form login request to the SaaS backend:
POST /login
Content-Type: application/x-www-form-urlencoded
[email protected]&password=secret
Step through the iteration:
toTest=UsernamePasswordAuthenticationToken.class- First provider:
DaoAuthenticationProvider.supports(UsernamePasswordAuthenticationToken.class)returnstrue. DaoAuthenticationProvider.authenticate()is called. It loads the user, checks the password, returns an authenticated token.- Loop breaks.
resultis non-null. eraseCredentials(result)sets the credentials field tonull.- The authenticated token is returned.
Now send a JWT request:
GET /api/tenants/tenant-a/data
Authorization: Bearer eyJhbGciOiJSUzI1NiIs...
Step through again:
toTest=BearerTokenAuthenticationToken.class- First provider:
DaoAuthenticationProvider.supports(BearerTokenAuthenticationToken.class)returnsfalse. Skip. - Second provider:
JwtAuthenticationProvider.supports(BearerTokenAuthenticationToken.class)returnstrue. JwtAuthenticationProvider.authenticate()decodes the JWT, validates it, returns an authenticated token.
Two completely different authentication mechanisms. One dispatch loop.
The Failure Mode
A developer on the SaaS team writes a custom AuthenticationProvider for API key authentication. They get the supports() method wrong:
// BROKEN: supports() returns true for all token types
public class ApiKeyAuthenticationProvider implements AuthenticationProvider {
@Override
public Authentication authenticate(Authentication authentication)
throws AuthenticationException {
// This provider only understands ApiKeyAuthenticationToken
if (authentication instanceof ApiKeyAuthenticationToken apiKey) {
return validateApiKey(apiKey);
}
// Falls through and returns null for other types
return null;
}
@Override
public boolean supports(Class<?> authentication) {
// BROKEN: claims to support everything
return true;
}
private Authentication validateApiKey(ApiKeyAuthenticationToken token) {
// validation logic
return token;
}
}
This breaks everything. When this provider appears first in the list, ProviderManager calls supports() with UsernamePasswordAuthenticationToken.class. It returns true. ProviderManager calls authenticate(). The method receives a UsernamePasswordAuthenticationToken, fails the instanceof check, and returns null.
ProviderManager treats a null return as “this provider could not authenticate” and continues to the next provider. So far, no crash. But the behavior is fragile. If the broken provider throws an AuthenticationException instead of returning null (a common mistake in error handling), it blocks all subsequent providers from being tried. The DaoAuthenticationProvider never gets a chance.
Worse: the broken supports() makes debugging harder. When you set a breakpoint on supports(), you see it returning true for every token type. The dispatch table becomes meaningless. Every provider appears to handle every request.
The Correct Pattern
// CORRECT: supports() returns true only for the specific token type
public class ApiKeyAuthenticationProvider implements AuthenticationProvider {
@Override
public Authentication authenticate(Authentication authentication)
throws AuthenticationException {
ApiKeyAuthenticationToken apiKey =
(ApiKeyAuthenticationToken) authentication;
String key = apiKey.getApiKey();
TenantApiKey resolved = apiKeyRepository.findByKey(key)
.orElseThrow(() -> new BadCredentialsException(
"Invalid API key"));
if (resolved.isExpired()) {
throw new CredentialsExpiredException(
"API key expired for tenant: " + resolved.getTenantId());
}
var authorities = List.of(
new SimpleGrantedAuthority("ROLE_API_CLIENT"));
return new ApiKeyAuthenticationToken(
resolved.getTenantId(), key, authorities);
}
@Override
public boolean supports(Class<?> authentication) {
return ApiKeyAuthenticationToken.class
.isAssignableFrom(authentication);
}
}
The supports() method returns true only for ApiKeyAuthenticationToken and its subclasses. The authenticate() method can safely cast because ProviderManager only calls it when supports() returned true. No instanceof check needed.
The custom token:
public class ApiKeyAuthenticationToken extends AbstractAuthenticationToken {
private final String tenantId;
private final String apiKey;
// Unauthenticated constructor
public ApiKeyAuthenticationToken(String apiKey) {
super(Collections.emptyList());
this.tenantId = null;
this.apiKey = apiKey;
setAuthenticated(false);
}
// Authenticated constructor
public ApiKeyAuthenticationToken(
String tenantId, String apiKey,
Collection<? extends GrantedAuthority> authorities) {
super(authorities);
this.tenantId = tenantId;
this.apiKey = apiKey;
setAuthenticated(true);
}
@Override
public Object getCredentials() { return apiKey; }
@Override
public Object getPrincipal() { return tenantId; }
public String getApiKey() { return apiKey; }
}
Register it alongside the other providers:
@Bean
public AuthenticationManager authenticationManager(
DaoAuthenticationProvider daoProvider,
JwtAuthenticationProvider jwtProvider,
ApiKeyAuthenticationProvider apiKeyProvider) {
return new ProviderManager(
List.of(daoProvider, jwtProvider, apiKeyProvider));
}
Three providers. Three token types. Each supports() method returns true for exactly one class hierarchy. The dispatch is clean, predictable, and debuggable.
The Mental Model
Think of ProviderManager as a type-based router. The Authentication token class is the route. The AuthenticationProvider is the handler. The supports() method is the route matcher.
This design is the Strategy pattern applied to authentication. The token type is the discriminator. Adding a new authentication mechanism means:
- Define a new
Authenticationtoken class. - Implement an
AuthenticationProviderwith a precisesupports()method. - Register the provider in the
ProviderManagerlist.
No changes to existing providers. No changes to ProviderManager. No changes to the filter chain (beyond adding a filter that extracts the new credential type and creates the token). The Open/Closed Principle in practice.
The parent AuthenticationManager adds one more dimension. A ProviderManager can delegate to a parent when none of its own providers match. In the SaaS backend, this enables per-tenant provider managers that fall back to a global one. Tenant-specific authentication logic in the child. Shared authentication logic in the parent. The next section covers this delegation chain in detail.