ProviderManager and the Authentication Provider Chain
The Iteration
ProviderManager.authenticate() is a linear scan. There is no hash map lookup, no pre-indexed routing table. It walks the provider list top to bottom. This matters when you debug.
Here is the full control flow, stripped of logging and minor details:
public Authentication authenticate(Authentication authentication)
throws AuthenticationException {
Class<? extends Authentication> toTest = authentication.getClass();
AuthenticationException lastException = null;
Authentication result = null;
Authentication parentResult = null;
for (AuthenticationProvider provider : getProviders()) {
if (!provider.supports(toTest)) {
continue;
}
try {
result = provider.authenticate(authentication);
if (result != null) {
copyDetails(authentication, result);
break;
}
} catch (AccountStatusException | InternalAuthenticationServiceException e) {
// These are definitive: do not try other providers
prepareException(e, authentication);
throw e;
} catch (AuthenticationException e) {
lastException = e;
}
}
if (result == null && parent != null) {
try {
parentResult = parent.authenticate(authentication);
result = parentResult;
} catch (AuthenticationException e) {
parentException = e;
lastException = e;
}
}
if (result != null) {
if (eraseCredentialsAfterAuthentication
&& result instanceof CredentialsContainer) {
((CredentialsContainer) result).eraseCredentials();
}
if (parentResult == null) {
eventPublisher.publishAuthenticationSuccess(result);
}
return result;
}
if (lastException == null) {
lastException = new ProviderNotFoundException(
"No AuthenticationProvider found for "
+ toTest.getName());
}
prepareException(lastException, authentication);
throw lastException;
}
Three critical behaviors to note:
First: AccountStatusException and InternalAuthenticationServiceException short-circuit the loop. If a provider determines the account is locked or disabled, no other provider gets a chance. This is correct. A locked account should not be rescued by a different authentication path.
Second: Other AuthenticationException subtypes (like BadCredentialsException) are captured but do not stop iteration. The loop continues. If a later provider succeeds, the earlier failure is discarded. If no provider succeeds, the last exception is thrown.
Third: A null return from authenticate() means “I matched the token type but chose not to authenticate.” The loop continues. This is distinct from throwing an exception.
The supports() Dispatch
supports() receives the token class, not the token instance. This design choice is deliberate. It allows ProviderManager to skip providers without requiring them to deserialize or inspect the token payload.
The check uses the raw Class object:
if (!provider.supports(toTest)) {
continue;
}
Most providers implement supports() using isAssignableFrom:
public boolean supports(Class<?> authentication) {
return UsernamePasswordAuthenticationToken.class
.isAssignableFrom(authentication);
}
This means a provider that supports UsernamePasswordAuthenticationToken also supports any subclass. If you create TenantPasswordToken extends UsernamePasswordAuthenticationToken, the default DaoAuthenticationProvider will match it.
When multiple providers support the same token type, the first one in the list that returns a non-null Authentication wins. The rest are never called. Provider ordering in the constructor is your priority system.
Parent ProviderManager Delegation
ProviderManager has a parent field of type AuthenticationManager. When no local provider handles the authentication, ProviderManager delegates to the parent.
This creates a chain. In the SaaS backend, you can structure it as:
@Configuration
public class AuthenticationConfig {
@Bean("globalAuthManager")
public AuthenticationManager globalAuthenticationManager(
DaoAuthenticationProvider systemAdminProvider) {
// Global provider: handles system admin accounts
return new ProviderManager(List.of(systemAdminProvider));
}
@Bean("tenantAuthManager")
public AuthenticationManager tenantAuthenticationManager(
@Qualifier("globalAuthManager")
AuthenticationManager globalManager,
TenantDaoAuthenticationProvider tenantProvider,
ApiKeyAuthenticationProvider apiKeyProvider) {
// Tenant providers with global fallback
ProviderManager manager = new ProviderManager(
List.of(tenantProvider, apiKeyProvider));
manager.setParent(globalManager);
return manager;
}
}
The resolution path for a UsernamePasswordAuthenticationToken:
tenantAuthManageriterates its providers.TenantDaoAuthenticationProvider.supports(UsernamePasswordAuthenticationToken.class)returnstrue.TenantDaoAuthenticationProvider.authenticate()tries to load the user from the tenant database.- If the user is not a tenant user (throws
UsernameNotFoundException), the exception is captured. - Loop finishes with no result.
tenantAuthManagerdelegates toglobalAuthManager.systemAdminProvider.supports(UsernamePasswordAuthenticationToken.class)returnstrue.systemAdminProvider.authenticate()loads the system admin. Succeeds.
The parent chain enables layered authentication. Tenant-specific logic first. Global fallback second. No conditional branching in application code.
AuthenticationEventPublisher
After a successful authentication, ProviderManager publishes an event:
if (parentResult == null) {
eventPublisher.publishAuthenticationSuccess(result);
}
The parentResult == null check prevents double-publishing. If the parent handled the authentication, the parent’s ProviderManager already published the event.
For failures, the prepareException method publishes:
private void prepareException(AuthenticationException ex,
Authentication auth) {
eventPublisher.publishAuthenticationFailure(ex, auth);
}
In the SaaS backend, wire an event listener for audit logging:
@Component
public class AuthenticationAuditListener {
private final AuditLogRepository auditLog;
@EventListener
public void onSuccess(AuthenticationSuccessEvent event) {
Authentication auth = event.getAuthentication();
auditLog.record(AuditEntry.builder()
.principal(auth.getName())
.action("LOGIN_SUCCESS")
.timestamp(Instant.now())
.build());
}
@EventListener
public void onFailure(AbstractAuthenticationFailureEvent event) {
auditLog.record(AuditEntry.builder()
.principal(event.getAuthentication().getName())
.action("LOGIN_FAILURE")
.reason(event.getException().getMessage())
.timestamp(Instant.now())
.build());
}
}
The default AuthenticationEventPublisher is DefaultAuthenticationEventPublisher, which maps exception types to specific event classes. BadCredentialsException maps to AuthenticationFailureBadCredentialsEvent. LockedException maps to AuthenticationFailureLockedEvent. You can register custom mappings:
@Bean
public AuthenticationEventPublisher authenticationEventPublisher(
ApplicationEventPublisher publisher) {
DefaultAuthenticationEventPublisher eventPublisher =
new DefaultAuthenticationEventPublisher(publisher);
eventPublisher.setAdditionalExceptionMappings(Map.of(
TenantSuspendedException.class,
AuthenticationFailureLockedEvent.class
));
return eventPublisher;
}
EraseCredentialsAfterAuthentication
After successful authentication, ProviderManager erases credentials from the returned token:
if (eraseCredentialsAfterAuthentication
&& result instanceof CredentialsContainer) {
((CredentialsContainer) result).eraseCredentials();
}
UsernamePasswordAuthenticationToken implements CredentialsContainer. Its eraseCredentials() method sets the credentials field to null and calls eraseCredentials() on the principal if it also implements CredentialsContainer.
This is enabled by default. After authentication completes, the raw password is gone from memory. The SecurityContextHolder holds the authenticated token with a null credentials field.
You can disable this if you need to re-authenticate downstream (rare, but possible in token relay scenarios):
ProviderManager manager = new ProviderManager(providers);
manager.setEraseCredentialsAfterAuthentication(false);
Do not disable this without a specific reason. Passwords lingering in heap memory are a vulnerability waiting for a heap dump.
The Failure Mode
A team building the SaaS backend configures tenant-specific authentication but forgets the parent delegation:
// BROKEN: no parent ProviderManager, global admin auth fails
@Bean
public AuthenticationManager authenticationManager(
TenantDaoAuthenticationProvider tenantProvider,
ApiKeyAuthenticationProvider apiKeyProvider) {
// No parent set. System admin login has no provider.
return new ProviderManager(
List.of(tenantProvider, apiKeyProvider));
}
TenantDaoAuthenticationProvider only loads users from tenant databases. When a system admin tries to log in, TenantDaoAuthenticationProvider throws UsernameNotFoundException. ApiKeyAuthenticationProvider does not support UsernamePasswordAuthenticationToken. No parent exists to fall back to.
Result: ProviderNotFoundException or the captured UsernameNotFoundException is thrown. System admins cannot log in. Tenant users work fine. The bug only surfaces when someone tries the admin login, which might be days after deployment.
The error message is unhelpful: “No AuthenticationProvider found for UsernamePasswordAuthenticationToken” or “User not found.” Neither tells you the problem is a missing parent manager.
The Correct Pattern
// CORRECT: parent ProviderManager handles global authentication
@Bean("globalAuthManager")
public AuthenticationManager globalAuthenticationManager(
SystemAdminAuthenticationProvider systemAdminProvider) {
return new ProviderManager(List.of(systemAdminProvider));
}
@Bean
public AuthenticationManager authenticationManager(
@Qualifier("globalAuthManager")
AuthenticationManager globalManager,
TenantDaoAuthenticationProvider tenantProvider,
ApiKeyAuthenticationProvider apiKeyProvider) {
ProviderManager manager = new ProviderManager(
List.of(tenantProvider, apiKeyProvider));
manager.setParent(globalManager);
return manager;
}
The resolution chain now works:
- Tenant user logs in:
TenantDaoAuthenticationProviderhandles it. Parent never called. - System admin logs in:
TenantDaoAuthenticationProviderfails.ApiKeyAuthenticationProviderskips (wrong token type). Parent’sSystemAdminAuthenticationProviderhandles it. - API key request:
TenantDaoAuthenticationProviderskips (wrong token type).ApiKeyAuthenticationProviderhandles it.
Every authentication path has a handler. The parent chain ensures nothing falls through.
To verify the chain at runtime, inject the AuthenticationManager and inspect it in a debugger. ProviderManager.getProviders() shows the local list. ProviderManager.getParent() shows the parent. Walk the chain until getParent() returns null. Every token type your application produces must have a matching supports() somewhere in that chain.