Spring Security: Custom UserDetailsService & AuthenticationProvider

Spring Security's authentication system is built around two extension points: UserDetailsService for loading user data by username, and AuthenticationProvider for complete control over how a credential is verified. Understanding both lets you connect any identity store (database, LDAP, API, or external system) to Spring Security.

Contents

Custom UserDetails Implementation

Implement UserDetails to carry not just the username and password but also your domain-specific fields (user ID, tenant ID, display name, etc.). This is the object placed in the SecurityContext and accessible via authentication.principal.

Java
import org.springframework.security.core.GrantedAuthority;
import org.springframework.security.core.authority.SimpleGrantedAuthority;
import org.springframework.security.core.userdetails.UserDetails;
import java.util.Collection;
import java.util.List;

public class AppUserDetails implements UserDetails {

    private final Long id;
    private final String username;
    private final String password;
    private final String email;
    private final boolean enabled;
    private final List<GrantedAuthority> authorities;

    public AppUserDetails(AppUser user) {
        this.id          = user.getId();
        this.username    = user.getUsername();
        this.password    = user.getPasswordHash();
        this.email       = user.getEmail();
        this.enabled     = user.isEnabled();
        this.authorities = user.getRoles().stream()
            .map(role -> new SimpleGrantedAuthority("ROLE_" + role.name()))
            .collect(java.util.stream.Collectors.toList());
    }

    // Custom getters for your domain fields
    public Long getId()     { return id; }
    public String getEmail(){ return email; }

    // UserDetails contract
    @Override public String getUsername()  { return username; }
    @Override public String getPassword()  { return password; }
    @Override public Collection<? extends GrantedAuthority> getAuthorities() { return authorities; }
    @Override public boolean isEnabled()             { return enabled; }
    @Override public boolean isAccountNonExpired()   { return true; }
    @Override public boolean isAccountNonLocked()    { return true; }
    @Override public boolean isCredentialsNonExpired(){ return true; }
}

Custom UserDetailsService

Implement UserDetailsService with a single method: loadUserByUsername(String username). Spring Security calls this during form-login and HTTP Basic authentication to load the user for credential verification.

Java
import org.springframework.security.core.userdetails.UserDetails;
import org.springframework.security.core.userdetails.UserDetailsService;
import org.springframework.security.core.userdetails.UsernameNotFoundException;
import org.springframework.stereotype.Service;

@Service
public class AppUserDetailsService implements UserDetailsService {

    private final UserRepository userRepository;

    public AppUserDetailsService(UserRepository userRepository) {
        this.userRepository = userRepository;
    }

    @Override
    public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
        AppUser user = userRepository.findByUsernameIgnoreCase(username)
            .orElseThrow(() -> new UsernameNotFoundException(
                "User not found: " + username));

        if (!user.isEnabled()) {
            throw new DisabledException("Account is disabled: " + username);
        }

        return new AppUserDetails(user);
    }
}
Always throw UsernameNotFoundException (not a generic exception) when the user is not found. Spring Security catches this and maps it to an authentication failure, preventing information leakage about whether a username exists.

PasswordEncoder Configuration

Never store plain-text passwords. Expose a PasswordEncoder bean: Spring Security uses it automatically to verify passwords during authentication and for encoding when you register users.

Java
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
import org.springframework.security.crypto.password.PasswordEncoder;

@Configuration
public class PasswordConfig {

    @Bean
    public PasswordEncoder passwordEncoder() {
        return new BCryptPasswordEncoder(12);  // strength 12 — ~300ms per hash on modern hardware
    }
}
Java
// Use DelegatingPasswordEncoder to support multiple algorithms during migration
@Bean
public PasswordEncoder passwordEncoder() {
    // Stores id prefix: {bcrypt}$2a$10$..., {noop}plaintext, {pbkdf2}...
    return PasswordEncoderFactories.createDelegatingPasswordEncoder();
}
Java
// Encoding when saving a new user
@Service
public class UserRegistrationService {
    private final UserRepository repo;
    private final PasswordEncoder encoder;

    public void register(RegisterRequest req) {
        AppUser user = new AppUser();
        user.setUsername(req.username());
        user.setPasswordHash(encoder.encode(req.password()));  // always encode before saving
        user.setRoles(Set.of(Role.USER));
        repo.save(user);
    }
}

Wiring into SecurityFilterChain

Spring Security auto-detects your UserDetailsService and PasswordEncoder beans and wires them into a DaoAuthenticationProvider automatically. You only need explicit wiring if you have multiple providers or want to customise the provider.

Java
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.authentication.AuthenticationManager;
import org.springframework.security.config.annotation.authentication.configuration.AuthenticationConfiguration;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.web.SecurityFilterChain;

@Configuration
public class SecurityConfig {

    // Expose AuthenticationManager for use in login endpoints
    @Bean
    public AuthenticationManager authenticationManager(
            AuthenticationConfiguration config) throws Exception {
        return config.getAuthenticationManager();
    }

    @Bean
    public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
        http
            .csrf(csrf -> csrf.disable())
            .authorizeHttpRequests(auth -> auth
                .requestMatchers("/api/auth/**").permitAll()
                .anyRequest().authenticated());
        return http.build();
    }
}
Java
// Typical login endpoint — uses the auto-configured AuthenticationManager
@RestController
@RequestMapping("/api/auth")
public class AuthController {

    private final AuthenticationManager authManager;
    private final JwtService jwtService;

    @PostMapping("/login")
    public ResponseEntity<TokenResponse> login(@RequestBody LoginRequest req) {
        Authentication auth = authManager.authenticate(
            new UsernamePasswordAuthenticationToken(req.username(), req.password())
        );
        AppUserDetails principal = (AppUserDetails) auth.getPrincipal();
        String token = jwtService.generateToken(principal);
        return ResponseEntity.ok(new TokenResponse(token));
    }
}

Custom AuthenticationProvider

Use a custom AuthenticationProvider when you need authentication logic that goes beyond username/password lookup, for example, OTP verification, API key validation, or multi-factor checks.

Java
import org.springframework.security.authentication.AuthenticationProvider;
import org.springframework.security.authentication.BadCredentialsException;
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.AuthenticationException;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.stereotype.Component;

@Component
public class CustomAuthenticationProvider implements AuthenticationProvider {

    private final AppUserDetailsService userDetailsService;
    private final PasswordEncoder passwordEncoder;
    private final OtpService otpService;

    public CustomAuthenticationProvider(AppUserDetailsService uds,
                                        PasswordEncoder pe,
                                        OtpService otp) {
        this.userDetailsService = uds;
        this.passwordEncoder    = pe;
        this.otpService         = otp;
    }

    @Override
    public Authentication authenticate(Authentication authentication) throws AuthenticationException {
        String username = authentication.getName();
        String password = authentication.getCredentials().toString();

        AppUserDetails user = (AppUserDetails) userDetailsService.loadUserByUsername(username);

        if (!passwordEncoder.matches(password, user.getPassword())) {
            throw new BadCredentialsException("Invalid password for: " + username);
        }

        // Extra step: verify OTP if the user has MFA enabled
        String otp = ((MfaAuthenticationToken) authentication).getOtp();
        if (!otpService.verify(user.getId(), otp)) {
            throw new BadCredentialsException("Invalid OTP");
        }

        return new UsernamePasswordAuthenticationToken(user, null, user.getAuthorities());
    }

    @Override
    public boolean supports(Class<?> authentication) {
        // Only handle MfaAuthenticationToken, not plain UsernamePasswordAuthenticationToken
        return MfaAuthenticationToken.class.isAssignableFrom(authentication);
    }
}

Multiple Authentication Sources

Register multiple AuthenticationProvider beans to support different authentication schemes (e.g., username/password for the UI, API key for machine-to-machine calls). Spring Security tries each provider in order until one succeeds.

Java
@Configuration
public class MultiAuthConfig {

    @Bean
    public SecurityFilterChain filterChain(HttpSecurity http,
                                           DaoAuthenticationProvider daoProvider,
                                           ApiKeyAuthenticationProvider apiKeyProvider) throws Exception {
        http
            .authenticationProvider(daoProvider)       // tried first
            .authenticationProvider(apiKeyProvider)    // tried second
            .csrf(csrf -> csrf.disable())
            .authorizeHttpRequests(auth -> auth.anyRequest().authenticated());
        return http.build();
    }

    @Bean
    public DaoAuthenticationProvider daoAuthenticationProvider(
            AppUserDetailsService uds, PasswordEncoder pe) {
        DaoAuthenticationProvider p = new DaoAuthenticationProvider();
        p.setUserDetailsService(uds);
        p.setPasswordEncoder(pe);
        return p;
    }
}