Contents

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.

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; } }

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.

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.

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.

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 } } // Use DelegatingPasswordEncoder to support multiple algorithms during migration @Bean public PasswordEncoder passwordEncoder() { // Stores id prefix: {bcrypt}$2a$10$..., {noop}plaintext, {pbkdf2}... return PasswordEncoderFactories.createDelegatingPasswordEncoder(); } // 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); } }

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.

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(); } } // 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)); } }

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.

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); } }

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.

@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; } }