Skip to main content
spring internals

DaoAuthenticationProvider and UserDetailsService

7 min read Chapter 36 of 78

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:

  1. Retrieve user: Call UserDetailsService.loadUserByUsername() (or fetch from cache).
  2. Pre-authentication checks: Verify the account is not locked, disabled, or expired.
  3. Additional authentication checks: Compare the submitted password against the stored hash.
  4. Post-authentication checks: Verify credentials are not expired.
  5. 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.