DaoAuthenticationProvider and UserDetailsService
The Provider
DaoAuthenticationProvider is the workhorse of username/password authentication. It extends AbstractUserDetailsAuthenticationProvider, which handles the control flow. DaoAuthenticationProvider plugs in two concrete operations: loading the user and checking the password.
The class hierarchy:
AuthenticationProvider
└── AbstractUserDetailsAuthenticationProvider
└── DaoAuthenticationProvider
AbstractUserDetailsAuthenticationProvider implements authenticate(). DaoAuthenticationProvider implements two abstract methods: retrieveUser() and additionalAuthenticationChecks().
The authenticate() Flow
When ProviderManager calls DaoAuthenticationProvider.authenticate(), the call enters AbstractUserDetailsAuthenticationProvider.authenticate(). Here is the sequence:
public Authentication authenticate(Authentication authentication)
throws AuthenticationException {
String username = determineUsername(authentication);
boolean cacheWasUsed = true;
UserDetails user = this.userCache.getUserFromCache(username);
if (user == null) {
cacheWasUsed = false;
user = retrieveUser(username,
(UsernamePasswordAuthenticationToken) authentication);
}
// Pre-authentication checks
this.preAuthenticationChecks.check(user);
// Password comparison
additionalAuthenticationChecks(user,
(UsernamePasswordAuthenticationToken) authentication);
// Post-authentication checks
this.postAuthenticationChecks.check(user);
if (!cacheWasUsed) {
this.userCache.putUserInCache(user);
}
return createSuccessAuthentication(
user.getUsername(), authentication, user);
}
Five steps in order:
- Retrieve user: Call
UserDetailsService.loadUserByUsername()(or fetch from cache). - Pre-authentication checks: Verify the account is not locked, disabled, or expired.
- Additional authentication checks: Compare the submitted password against the stored hash.
- Post-authentication checks: Verify credentials are not expired.
- Create success token: Build and return an authenticated
UsernamePasswordAuthenticationToken.
Each step can throw an AuthenticationException subclass. UsernameNotFoundException from step 1. LockedException or DisabledException from step 2. BadCredentialsException from step 3. CredentialsExpiredException from step 4.
UserDetailsService and the UserDetails Contract
DaoAuthenticationProvider.retrieveUser() delegates to UserDetailsService:
public interface UserDetailsService {
UserDetails loadUserByUsername(String username)
throws UsernameNotFoundException;
}
UserDetails is the contract for user information:
public interface UserDetails extends Serializable {
Collection<? extends GrantedAuthority> getAuthorities();
String getPassword();
String getUsername();
boolean isAccountNonExpired();
boolean isAccountNonLocked();
boolean isCredentialsNonExpired();
boolean isEnabled();
}
Four boolean methods control account status. All four must return true for authentication to succeed. Spring checks isAccountNonLocked(), isEnabled(), and isAccountNonExpired() in pre-authentication checks (before the password comparison). isCredentialsNonExpired() is checked in post-authentication checks (after the password comparison).
This ordering is deliberate. Checking account status before the password prevents timing attacks. If the account is locked, Spring rejects immediately without spending CPU cycles on BCrypt.checkpw(). An attacker cannot distinguish “locked account with wrong password” from “locked account with right password” based on response time.
The SaaS Backend: TenantUserDetailsService
The multi-tenant SaaS backend loads users from a tenant-specific schema:
@Service
public class TenantUserDetailsService implements UserDetailsService {
private final TenantUserRepository userRepository;
private final TenantContext tenantContext;
@Override
public UserDetails loadUserByUsername(String username)
throws UsernameNotFoundException {
String tenantId = tenantContext.getCurrentTenantId();
TenantUser user = userRepository
.findByUsernameAndTenantId(username, tenantId)
.orElseThrow(() -> new UsernameNotFoundException(
"User not found: " + username));
return new org.springframework.security.core.userdetails.User(
user.getUsername(),
user.getPasswordHash(),
user.isActive(), // enabled
true, // accountNonExpired
!user.isPasswordExpired(), // credentialsNonExpired
!user.isLocked(), // accountNonLocked
mapAuthorities(user.getRoles())
);
}
private Collection<SimpleGrantedAuthority> mapAuthorities(
Set<String> roles) {
return roles.stream()
.map(role -> new SimpleGrantedAuthority("ROLE_" + role))
.collect(Collectors.toSet());
}
}
The TenantContext resolves the current tenant from the request (set earlier in the filter chain). The query scopes by both username and tenant ID. User “admin” in tenant A is a different row from “admin” in tenant B.
PasswordEncoder and the Comparison Flow
DaoAuthenticationProvider.additionalAuthenticationChecks() compares passwords:
protected void additionalAuthenticationChecks(
UserDetails userDetails,
UsernamePasswordAuthenticationToken authentication)
throws AuthenticationException {
if (authentication.getCredentials() == null) {
throw new BadCredentialsException("Bad credentials");
}
String presentedPassword = authentication.getCredentials().toString();
if (!passwordEncoder.matches(
presentedPassword, userDetails.getPassword())) {
throw new BadCredentialsException("Bad credentials");
}
}
PasswordEncoder.matches(rawPassword, encodedPassword) handles the comparison. The PasswordEncoder interface:
public interface PasswordEncoder {
String encode(CharSequence rawPassword);
boolean matches(CharSequence rawPassword, String encodedPassword);
default boolean upgradeEncoding(String encodedPassword) {
return false;
}
}
BCryptPasswordEncoder is the default recommendation. It stores the salt inside the hash string itself ($2a$10$...), so no separate salt column is needed.
DelegatingPasswordEncoder is the production choice. It prefixes the hash with an algorithm identifier:
{bcrypt}$2a$10$dXJ3SW6G7P50lGmMQoeqxu...
{sha256}97cde38028ad898ebc02e53e...
{noop}plainTextPassword
When matches() is called, DelegatingPasswordEncoder reads the prefix, selects the corresponding encoder, and delegates. This enables password hash migration: old SHA-256 hashes coexist with new BCrypt hashes. When a user logs in with an old hash, the application can re-hash with BCrypt and update the database.
@Bean
public PasswordEncoder passwordEncoder() {
return PasswordEncoderFactories.createDelegatingPasswordEncoder();
}
PasswordEncoderFactories.createDelegatingPasswordEncoder() creates a DelegatingPasswordEncoder with BCrypt as the default for encoding and support for reading {bcrypt}, {scrypt}, {pbkdf2}, {sha256}, {argon2}, and {noop} prefixed hashes.
Caching UserDetails
AbstractUserDetailsAuthenticationProvider supports a UserCache. By default, it uses NullUserCache (no caching). For high-throughput endpoints in the SaaS backend where the same user authenticates multiple times per second, caching avoids repeated database queries:
@Bean
public DaoAuthenticationProvider daoAuthenticationProvider(
TenantUserDetailsService userDetailsService,
PasswordEncoder passwordEncoder) {
DaoAuthenticationProvider provider = new DaoAuthenticationProvider();
provider.setUserDetailsService(userDetailsService);
provider.setPasswordEncoder(passwordEncoder);
provider.setUserCache(new SpringCacheBasedUserCache(
cacheManager.getCache("userDetails")));
return provider;
}
The cache key is the username. When a user changes their password or an admin disables the account, the cache entry must be evicted. Stale cache entries are a security issue: a disabled user keeps authenticating until the cache expires.
@EventListener
public void onPasswordChange(PasswordChangedEvent event) {
Objects.requireNonNull(
cacheManager.getCache("userDetails"))
.evict(event.getUsername());
}
The Failure Mode
A developer implements a custom PasswordEncoder for legacy compatibility. The implementation has a critical flaw:
// BROKEN: matches() returns true when input is empty
public class LegacySha256PasswordEncoder implements PasswordEncoder {
@Override
public String encode(CharSequence rawPassword) {
return DigestUtils.sha256Hex(rawPassword.toString());
}
@Override
public boolean matches(CharSequence rawPassword, String encodedPassword) {
if (rawPassword == null || encodedPassword == null) {
return false;
}
// BROKEN: empty string hashes to a valid SHA-256 value
// An attacker sending an empty password gets SHA-256("") =
// "e3b0c44298fc1c149afbf4c8996fb924..."
// This won't match most stored hashes, but...
String encoded = encode(rawPassword);
return encoded.equals(encodedPassword);
}
}
The SHA-256 approach has two compounding problems. First, SHA-256 without a salt means identical passwords produce identical hashes across all users and tenants. An attacker with read access to the database can identify users with the same password by comparing hashes. Second, SHA-256 is a fast hash. Modern GPUs compute billions of SHA-256 hashes per second. A dictionary attack against the entire user table completes in minutes.
But the deeper bug is in how this encoder is used with DelegatingPasswordEncoder. If the legacy encoder is registered as a delegate but the stored hashes have no prefix:
// Stored in database: e3b0c44298fc1c149afbf4c8996fb924...
// No {sha256} prefix. DelegatingPasswordEncoder cannot route it.
DelegatingPasswordEncoder fails to find a matching delegate and falls through to the default. If the default is BCrypt, it tries to parse a SHA-256 hex string as a BCrypt hash and throws an IllegalArgumentException. The application crashes instead of returning a clean authentication failure.
The Correct Pattern
// CORRECT: BCryptPasswordEncoder with DelegatingPasswordEncoder for migration
@Bean
public PasswordEncoder passwordEncoder() {
// Default factory: BCrypt for new passwords, delegates for legacy
return PasswordEncoderFactories.createDelegatingPasswordEncoder();
}
For migrating legacy SHA-256 hashes, prefix them in the database first:
UPDATE users
SET password_hash = CONCAT('{sha256}', password_hash)
WHERE password_hash NOT LIKE '{%}';
Then implement password upgrade on login:
@Service
public class PasswordUpgradeService implements AuthenticationSuccessHandler {
private final TenantUserRepository userRepository;
private final PasswordEncoder passwordEncoder;
@Override
public void onAuthenticationSuccess(
HttpServletRequest request,
HttpServletResponse response,
Authentication authentication) {
if (authentication.getCredentials() != null) {
String username = authentication.getName();
String rawPassword = authentication.getCredentials().toString();
userRepository.findByUsername(username).ifPresent(user -> {
if (passwordEncoder.upgradeEncoding(
user.getPasswordHash())) {
String newHash = passwordEncoder.encode(rawPassword);
user.setPasswordHash(newHash);
userRepository.save(user);
}
});
}
}
}
DelegatingPasswordEncoder.upgradeEncoding() returns true when the stored hash uses a non-default algorithm. If the stored hash starts with {sha256} and the default encoder is BCrypt, upgradeEncoding() returns true. The handler re-encodes with BCrypt and saves.
Over time, all users migrate to BCrypt through normal login activity. After sufficient time passes and login analytics confirm no {sha256} hashes remain, remove the legacy encoder from the delegate map.
The correct configuration for the SaaS backend:
@Bean
public DaoAuthenticationProvider daoAuthenticationProvider(
TenantUserDetailsService userDetailsService,
PasswordEncoder passwordEncoder) {
DaoAuthenticationProvider provider = new DaoAuthenticationProvider();
provider.setUserDetailsService(userDetailsService);
provider.setPasswordEncoder(passwordEncoder);
provider.setHideUserNotFoundExceptions(true);
return provider;
}
setHideUserNotFoundExceptions(true) is the default. It converts UsernameNotFoundException to BadCredentialsException. This prevents username enumeration: an attacker cannot distinguish “user does not exist” from “wrong password” based on the error message or response code. Both return the same BadCredentialsException with the same “Bad credentials” message.
Leave this default in place. The only reason to set it to false is during development when debugging user lookup issues. Never disable it in production.