Spring BootUserDetailsService
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 thrown

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