Skip to content
Ashish.
All posts
Diagram illustrating the Spring Security authentication flow with UserDetailsService as the central bridge.

Building a Custom UserDetailsService with Spring Security

Learn how to implement a custom UserDetailsService in Spring Security to handle user loading and GrantedAuthority logic.

By Ashish SrivastavaPart 1 of Machine Identity & DevSecOps Series

Building a Custom UserDetails Service with Spring Security

In the Spring Security architecture, the moment a user submits credentials, the system does not immediately know who they are. It only knows a String username and a String password. The bridge between these opaque strings and the actionable security context is the UserDetailsService. This interface is the single point of truth where the application defines how to translate a principal identifier into a UserDetails object. Without a custom implementation, Spring defaults to an in-memory list or a simple JDBC loader, but production systems require a mechanism that fetches user data, maps roles, and manages state efficiently. The core mechanism here is delegation: the AuthenticationProvider delegates the heavy lifting of data retrieval to the UserDetailsService, which then returns a UserDetails instance containing the encoded password and a collection of GrantedAuthority objects.

This guide, Part 1 of the Machine Identity & DevSecOps Series, explores the implementation details of this service, focusing on the lifecycle of the loadUserByUsername call, the construction of GrantedAuthority objects, and critical caching strategies to optimize performance without compromising security.

The Loading Mechanism and Lifecycle

The loadUserByUsername method is the entry point. It is called synchronously during the authentication phase. If this method throws a UsernameNotFoundException, the DaoAuthenticationProvider typically re-throws the exception or wraps it in a BadCredentialsException to preserve context, rather than converting it to a generic string message that might reveal user existence. The mechanism relies on the fact that the UserDetails object is created fresh for every authentication attempt unless caching is explicitly enabled.

Consider a scenario involving two actors: the AuthenticationManager (which orchestrates the authentication process) and UserService (your custom implementation). When a controller constructs an Authentication token for a login attempt for "alice" and passes it to the AuthenticationManager, the DaoAuthenticationProvider intercepts this token and calls userService.loadUserByUsername("alice"). This is the critical mechanism. The service must return a UserDetails object that is immutable once constructed. If you modify the returned object, you risk corrupting the SecurityContext held in the thread-local storage. The service does not verify the password; it only retrieves the stored hash. The DaoAuthenticationProvider handles the cryptographic comparison against the hash provided by your service.

Imagine UserService fetching data from a PostgreSQL database. The service executes a query to join the users table with the roles table. It constructs a User object (which implements UserDetails). The password field in this object must contain the stored hash from the database, typically a bcrypt hash like $2a$10$.... The service does not decrypt or hash this string; it simply passes it through.

@Service
public class CustomUserDetailsService implements UserDetailsService {
 
    private final UserRepository userRepository;
    private final RoleMapper roleMapper;
 
    public CustomUserDetailsService(UserRepository userRepository, RoleMapper roleMapper) {
        this.userRepository = userRepository;
        this.roleMapper = roleMapper;
    }
 
    @Override
    public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
        UserEntity userEntity = userRepository.findByUsername(username)
                .orElseThrow(() -> new UsernameNotFoundException("User " + username + " not found"));
        
        // Map database roles to Spring GrantedAuthorities
        Collection<GrantedAuthority> authorities = roleMapper.mapToAuthorities(userEntity.getRoles());
        
        // Construct the UserDetails object
        // Note: The password here is the raw hash from the DB, not the plaintext
        return org.springframework.security.core.userdetails.User
                .withUsername(userEntity.getUsername())
                .password(userEntity.getPassword())
                .authorities(authorities)
                .accountLocked(userEntity.isLocked())
                .accountExpired(userEntity.isExpired())
                .credentialsExpired(userEntity.isCredentialsExpired())
                .disabled(userEntity.isDisabled())
                .build();
    }
}

In this code, the mechanism is explicit: UserRepository fetches the entity, RoleMapper translates the domain-specific role names (e.g., "ADMIN_ROLE") into Spring's expected format (e.g., "ROLE_ADMIN"), and the User builder constructs the immutable UserDetails object. The build() method finalizes the object, ensuring no further mutation occurs.

Constructing GrantedAuthorities

The GrantedAuthority interface represents a permission. In Spring Security, this is almost always a String starting with ROLE_ (though other prefixes like PERMISSION_ are valid). The mechanism for mapping these roles is critical because the AccessDecisionManager later checks these strings against the @PreAuthorize expressions or URL security configurations.

If your database stores roles as simple integers or UUIDs, you must map them to strings. A common pitfall is returning null or an empty collection for the authorities. If authorities is empty, the user has no permissions, and even if they authenticate, they cannot access any protected resource.

Consider the RoleMapper logic. It iterates over the user's roles and creates SimpleGrantedAuthority instances.

public class RoleMapper {
    public static Collection<GrantedAuthority> mapToAuthorities(List<Role> roles) {
        if (roles == null || roles.isEmpty()) {
            return List.of();
        }
        return roles.stream()
                .map(role -> new SimpleGrantedAuthority("ROLE_" + role.getName()))
                .collect(Collectors.toList());
    }
}

Here, role.getName() might return "ADMIN". The mapper prepends "ROLE_" to create "ROLE_ADMIN". This string is what gets stored in the GrantedAuthority object. When the SecurityContext is populated, it holds a list of these SimpleGrantedAuthority objects. The mechanism ensures that the authority string is consistent with the configuration in your SecurityFilterChain.

Caching Strategies for Performance

Loading user details from a database on every authentication request introduces latency. The mechanism for caching must be applied carefully. Spring Security provides a Cacheable annotation approach, but the placement of the cache is crucial.

If you cache the UserDetails object itself, you must ensure that the cache key is the username. However, if the user's roles change in the database while the session is active, the cached UserDetails becomes stale. The standard pattern is to cache the result of loadUserByUsername at the service layer.

@Service
public class CachedUserDetailsService implements UserDetailsService {
 
    private final UserDetailsService delegate;
    private final Cache cache;
 
    public CachedUserDetailsService(UserDetailsService delegate, CacheManager cacheManager) {
        this.delegate = delegate;
        this.cache = cacheManager.getCache("userDetailsCache");
    }
 
    @Override
    public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
        // Attempt to get from cache
        UserDetails cachedUser = cache.get(username, UserDetails.class);
        if (cachedUser != null) {
            return cachedUser;
        }
 
        // Load from source if not in cache
        UserDetails userDetails = delegate.loadUserByUsername(username);
        
        // Store in cache
        cache.put(username, userDetails);
        
        return userDetails;
    }
}

The mechanism here involves a check-then-load-then-store pattern. The cache.get method uses the username as the key. If the data is missing, it delegates to the underlying service. The cache.put stores the immutable UserDetails object. This reduces database load significantly. However, this is an opinionated trade-off: if your application requires immediate role updates to take effect (e.g., an admin revoking a user's access), you must implement an invalidation strategy, such as listening to database events or using a time-to-live (TTL) cache with a short expiration. Relying solely on a static cache without invalidation can lead to security vulnerabilities where a revoked user retains access until the cache expires.

Password Handling and Encoding

A common misconception is that the UserDetailsService should hash the password. It should not. The PasswordEncoder bean is configured separately and is responsible for the hashing algorithm (e.g., BCrypt, Argon2). The UserDetailsService must return the stored hash from your database.

When the DaoAuthenticationProvider receives the UserDetails object, it calls passwordEncoder.matches(rawPassword, encodedPassword). The rawPassword comes from the Authentication token (the user's input), and encodedPassword comes from the UserDetails object returned by your service. The PasswordEncoder performs the one-way hash comparison.

If your service returns a plaintext password, the PasswordEncoder will fail to match it against the stored hash, resulting in a login failure. Conversely, if you hash the password in the service, you will end up double-hashing the password, which is incorrect. The mechanism is strictly: Service returns hash, Provider verifies hash.

Common Pitfalls

Implementing UserDetailsService involves several subtle traps that can compromise security or functionality.

  1. Caching Invalidation Risks: Caching UserDetails improves performance but introduces staleness. If a user's roles are updated in the database but the cache isn't invalidated, the user retains old permissions. You must implement a robust invalidation strategy, such as clearing the cache on role updates or using a short TTL.
  2. Danger of Null Authorities: Returning null or an empty collection for GrantedAuthority results in a user with zero permissions. While this prevents unauthorized access, it can break authentication flows if the application expects at least one role. Always return an empty list (List.of()) rather than null if no roles exist.
  3. Service Hashing vs. Provider Verification: Do not hash the password inside UserDetailsService. The service's sole responsibility is retrieving the stored hash. Hashing in the service leads to double-hashing, causing login failures. The PasswordEncoder in the DaoAuthenticationProvider is the only component that should perform the comparison logic.

Practical Takeaways

To implement a secure and efficient UserDetailsService, keep these steps in mind:

  • Immutability: Ensure the UserDetails object returned is immutable after construction to protect the SecurityContext.
  • Encoding Separation: Never hash passwords in the service layer; rely on the PasswordEncoder bean configured in your security chain.
  • Caching Strategy: Implement caching at the service layer only if you have a clear plan for invalidating stale data to maintain security consistency.

FAQ

Can I cache UserDetails? Yes, but you must carefully manage invalidation. Caching the UserDetails object is safe for read-only scenarios, but if roles change dynamically, you need a strategy to clear the cache when user data is updated to prevent privilege escalation.

What happens if loadUserByUsername throws an exception? If loadUserByUsername throws a UsernameNotFoundException, the DaoAuthenticationProvider typically wraps it in a BadCredentialsException. This prevents user enumeration attacks by providing a generic failure message to the client while logging the specific root cause.

Do I need to hash passwords in the service? No. The UserDetailsService should retrieve the pre-hashed password from your database and pass it directly to the UserDetails object. The DaoAuthenticationProvider handles the verification against the user's input using the configured PasswordEncoder.

Conclusion

Building a custom UserDetailsService is about defining the contract between your data model and Spring Security's authentication engine. The mechanism relies on the loadUserByUsername method to fetch user data, map roles to GrantedAuthority strings, and return an immutable UserDetails object containing the stored password hash. Caching at the service level improves performance but requires careful invalidation logic to maintain security consistency. By understanding that the service does not perform authentication logic but rather provides the necessary context for the provider to do so, you can build security implementations that align with your application's specific data requirements.

Related posts