Spring Boot
UserDetailsService
UserDetailsService is the core Spring Security interface for loading user data during authentication. It has a single method — loadUserByUsername — that returns a UserDetails object containing the username, password, and granted authorities. Spring Security calls it automatically during form login, HTTP Basic, and any authentication mechanism that needs to verify credentials. This entry covers the interface, custom implementations, UserDetails, and integration with the authentication flow.
UserDetailsService Interface
UserDetailsService has one method: loadUserByUsername(String username). It returns a UserDetails object or throws UsernameNotFoundException if the user does not exist. Spring Security calls it during authentication to load the stored credentials and authorities, then compares them against what the user supplied.
Java
// ── The interface ─────────────────────────────────────────────────────
public interface UserDetailsService {
UserDetails loadUserByUsername(String username)
throws UsernameNotFoundException;
}
// ── UserDetails — what Spring Security needs ──────────────────────────
public interface UserDetails extends Serializable {
Collection<? extends GrantedAuthority> getAuthorities();
String getPassword();
String getUsername();
boolean isAccountNonExpired();
boolean isAccountNonLocked();
boolean isCredentialsNonExpired();
boolean isEnabled();
}
// ── GrantedAuthority — a single permission or role ────────────────────
public interface GrantedAuthority extends Serializable {
String getAuthority(); // e.g. "ROLE_USER", "ROLE_ADMIN", "READ_PRODUCTS"
}
// ── How Spring Security uses UserDetailsService ───────────────────────
//
// 1. User submits credentials (username + password)
// 2. AuthenticationManager receives UsernamePasswordAuthenticationToken
// 3. DaoAuthenticationProvider calls userDetailsService.loadUserByUsername()
// 4. UserDetails returned — password compared by PasswordEncoder
// 5. If match → SecurityContext populated with authenticated principal
// 6. If no match or user not found → AuthenticationException thrownCustom UserDetailsService
Implement UserDetailsService to load users from a database, LDAP, or any custom source. Annotate with @Service so Spring Security's auto-configuration picks it up automatically. Build the UserDetails using Spring Security's User builder or a custom UserDetails implementation that carries extra fields.
Java
// ── User entity ───────────────────────────────────────────────────────
@Entity
@Table(name = "users")
@Getter @Setter @NoArgsConstructor
public class User {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@Column(nullable = false, unique = true, length = 255)
private String email;
@Column(nullable = false)
private String password;
@Column(nullable = false)
private boolean enabled = true;
@Column(nullable = false)
private boolean accountNonLocked = true;
@ElementCollection(fetch = FetchType.EAGER)
@CollectionTable(name = "user_roles",
joinColumns = @JoinColumn(name = "user_id"))
@Column(name = "role")
private Set<String> roles = new HashSet<>();
}
// ── Repository ────────────────────────────────────────────────────────
public interface UserRepository extends JpaRepository<User, Long> {
Optional<User> findByEmail(String email);
boolean existsByEmail(String email);
}
// ── Custom UserDetailsService ─────────────────────────────────────────
@Service
@RequiredArgsConstructor
@Slf4j
public class CustomUserDetailsService implements UserDetailsService {
private final UserRepository userRepo;
@Override
@Transactional(readOnly = true)
public UserDetails loadUserByUsername(String email)
throws UsernameNotFoundException {
User user = userRepo.findByEmail(email)
.orElseThrow(() -> {
log.warn("User not found for email: {}", email);
// Throw generic message — do not reveal whether the
// email exists to prevent user enumeration attacks
return new UsernameNotFoundException(
"Invalid credentials");
});
return org.springframework.security.core.userdetails.User
.withUsername(user.getEmail())
.password(user.getPassword())
.authorities(buildAuthorities(user.getRoles()))
.accountExpired(false)
.accountLocked(!user.isAccountNonLocked())
.credentialsExpired(false)
.disabled(!user.isEnabled())
.build();
}
private List<GrantedAuthority> buildAuthorities(Set<String> roles) {
return roles.stream()
.map(role -> (GrantedAuthority)
new SimpleGrantedAuthority(
role.startsWith("ROLE_") ? role : "ROLE_" + role))
.toList();
}
}Custom UserDetails Implementation
Spring's User builder returns a basic UserDetails. For APIs that need the authenticated user's ID or other fields downstream, create a custom UserDetails class that carries those fields and implement UserDetails directly. Inject it as the @AuthenticationPrincipal in controllers.
Java
// ── Custom UserDetails carrying the database ID ───────────────────────
@Getter
@AllArgsConstructor
public class AuthenticatedUser implements UserDetails {
private final Long id;
private final String email;
private final String password;
private final boolean enabled;
private final boolean accountNonLocked;
private final Collection<GrantedAuthority> authorities;
// Static factory from entity
public static AuthenticatedUser from(User user) {
List<GrantedAuthority> auths = user.getRoles().stream()
.map(r -> new SimpleGrantedAuthority(
r.startsWith("ROLE_") ? r : "ROLE_" + r))
.collect(Collectors.toList());
return new AuthenticatedUser(
user.getId(),
user.getEmail(),
user.getPassword(),
user.isEnabled(),
user.isAccountNonLocked(),
auths
);
}
@Override public String getUsername() { return email; }
@Override public boolean isAccountNonExpired() { return true; }
@Override public boolean isAccountNonLocked() { return accountNonLocked; }
@Override public boolean isCredentialsNonExpired() { return true; }
}
// ── Updated UserDetailsService returning custom UserDetails ───────────
@Service
@RequiredArgsConstructor
public class CustomUserDetailsService implements UserDetailsService {
private final UserRepository userRepo;
@Override
@Transactional(readOnly = true)
public UserDetails loadUserByUsername(String email)
throws UsernameNotFoundException {
User user = userRepo.findByEmail(email)
.orElseThrow(() ->
new UsernameNotFoundException("Invalid credentials"));
return AuthenticatedUser.from(user);
}
}
// ── Controller accessing the authenticated user's ID ──────────────────
@RestController
@RequestMapping("/api/v1/profile")
public class ProfileController {
@GetMapping
public ResponseEntity<ProfileResponse> getProfile(
@AuthenticationPrincipal AuthenticatedUser principal) {
// Access the database ID directly — no extra lookup needed
return ResponseEntity.ok(
profileService.findById(principal.getId()));
}
@PutMapping
public ResponseEntity<ProfileResponse> updateProfile(
@AuthenticationPrincipal AuthenticatedUser principal,
@RequestBody @Valid UpdateProfileRequest request) {
return ResponseEntity.ok(
profileService.update(principal.getId(), request));
}
}SecurityFilterChain and AuthenticationProvider
Wire the custom UserDetailsService into Spring Security's authentication flow through a SecurityFilterChain bean and a DaoAuthenticationProvider. The provider uses the UserDetailsService to load users and a PasswordEncoder to verify passwords.
Java
@Configuration
@EnableWebSecurity
@RequiredArgsConstructor
public class SecurityConfig {
private final CustomUserDetailsService userDetailsService;
private final PasswordEncoder passwordEncoder;
// ── DaoAuthenticationProvider ─────────────────────────────────────
@Bean
public DaoAuthenticationProvider authenticationProvider() {
DaoAuthenticationProvider provider =
new DaoAuthenticationProvider();
provider.setUserDetailsService(userDetailsService);
provider.setPasswordEncoder(passwordEncoder);
// Hide UsernameNotFoundException behind BadCredentialsException
provider.setHideUserNotFoundExceptions(true);
return provider;
}
// ── AuthenticationManager ─────────────────────────────────────────
@Bean
public AuthenticationManager authenticationManager(
AuthenticationConfiguration config) throws Exception {
return config.getAuthenticationManager();
}
// ── SecurityFilterChain ───────────────────────────────────────────
@Bean
public SecurityFilterChain filterChain(HttpSecurity http)
throws Exception {
return http
.csrf(AbstractHttpConfigurer::disable)
.sessionManagement(sm -> sm
.sessionCreationPolicy(
SessionCreationPolicy.STATELESS))
.authenticationProvider(authenticationProvider())
.authorizeHttpRequests(auth -> auth
.requestMatchers("/api/v1/auth/**").permitAll()
.requestMatchers("/actuator/health").permitAll()
.anyRequest().authenticated())
.build();
}
}
// ── Authentication service — programmatic login ───────────────────────
@Service
@RequiredArgsConstructor
public class AuthService {
private final AuthenticationManager authManager;
public AuthenticatedUser authenticate(String email,
String password) {
Authentication auth = authManager.authenticate(
new UsernamePasswordAuthenticationToken(email, password));
return (AuthenticatedUser) auth.getPrincipal();
}
}UserDetailsService Caching
loadUserByUsername is called on every authenticated request when using stateless JWT authentication. Cache the result to avoid a database query per request. Use Spring Cache with a short TTL so stale credentials (disabled account, changed password) are picked up promptly.
Java
// ── Cached UserDetailsService ─────────────────────────────────────────
@Service
@RequiredArgsConstructor
@Slf4j
public class CachedUserDetailsService implements UserDetailsService {
private final UserRepository userRepo;
@Override
@Cacheable(value = "userDetails", key = "#email",
unless = "#result == null")
@Transactional(readOnly = true)
public UserDetails loadUserByUsername(String email)
throws UsernameNotFoundException {
log.debug("Loading user from DB: {}", email);
User user = userRepo.findByEmail(email)
.orElseThrow(() ->
new UsernameNotFoundException("Invalid credentials"));
return AuthenticatedUser.from(user);
}
// ── Evict cache on password change or account update ──────────────
@CacheEvict(value = "userDetails", key = "#email")
public void evict(String email) {
log.debug("Evicted userDetails cache for: {}", email);
}
}
// ── Cache configuration — 5-minute TTL ───────────────────────────────
@Configuration
@EnableCaching
public class CacheConfig {
@Bean
public CacheManager cacheManager(
RedisConnectionFactory factory) {
RedisCacheConfiguration config = RedisCacheConfiguration
.defaultCacheConfig()
.entryTtl(Duration.ofMinutes(5))
.disableCachingNullValues();
return RedisCacheManager.builder(factory)
.withCacheConfiguration("userDetails", config)
.build();
}
}
// ── Evict on security-sensitive changes ───────────────────────────────
@Service
@RequiredArgsConstructor
public class UserService {
private final UserRepository userRepo;
private final CachedUserDetailsService cachedService;
private final PasswordEncoder encoder;
@Transactional
public void changePassword(Long userId, String newPassword) {
User user = userRepo.findById(userId).orElseThrow();
user.setPassword(encoder.encode(newPassword));
userRepo.save(user);
cachedService.evict(user.getEmail()); // force re-load
}
@Transactional
public void disableAccount(Long userId) {
User user = userRepo.findById(userId).orElseThrow();
user.setEnabled(false);
userRepo.save(user);
cachedService.evict(user.getEmail()); // force re-load
}
}