Spring Security Fundamentals

Users & UserDetailsService

18 min Lesson 5 of 13

Users & UserDetailsService

Spring Security does not talk to your database directly. Instead it delegates the job of loading a user to a single interface: UserDetailsService. Whatever backing store you use — an in-memory map, a relational database, LDAP, or a remote microservice — you wrap it in a class that implements this interface and Spring Security works the same way. This decoupling is one of the clearest design decisions in the framework, and understanding it is the key to customizing authentication.

The Core Contracts

Two interfaces carry almost all of the weight:

  • UserDetailsService — a single-method interface. You implement loadUserByUsername(String username) and return a UserDetails object. Spring Security calls it during authentication.
  • UserDetails — represents the principal. It exposes the hashed password, the collection of GrantedAuthority objects (roles/permissions), and four boolean flags: isEnabled, isAccountNonExpired, isAccountNonLocked, isCredentialsNonExpired.

If the username does not exist, loadUserByUsername must throw UsernameNotFoundException, never return null. Returning null causes a NullPointerException buried inside the framework — always throw.

In-Memory Users — Fast Setup for Development

The quickest way to configure users is to provide an InMemoryUserDetailsManager bean. This is appropriate for demos, automated tests, and local development — not for production.

import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; import org.springframework.security.core.userdetails.User; import org.springframework.security.core.userdetails.UserDetails; import org.springframework.security.core.userdetails.UserDetailsService; import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder; import org.springframework.security.crypto.password.PasswordEncoder; import org.springframework.security.provisioning.InMemoryUserDetailsManager; @Configuration public class InMemorySecurityConfig { @Bean public PasswordEncoder passwordEncoder() { return new BCryptPasswordEncoder(); } @Bean public UserDetailsService userDetailsService(PasswordEncoder encoder) { UserDetails alice = User.builder() .username("alice") .password(encoder.encode("aliceSecret")) .roles("USER") .build(); UserDetails admin = User.builder() .username("admin") .password(encoder.encode("adminSecret")) .roles("USER", "ADMIN") .build(); return new InMemoryUserDetailsManager(alice, admin); } }
Why encode even in-memory passwords? Spring Security 6 requires that every stored password carries an encoding prefix (e.g. {bcrypt}$2a$...) or is supplied through a PasswordEncoder bean. If you use User.withDefaultPasswordEncoder() you will see a deprecation warning — it is only for samples, not real code. Always wire in a PasswordEncoder bean explicitly.

The roles("USER") helper is syntactic sugar: it creates a GrantedAuthority with the value ROLE_USER. If you need authority names that do not follow the ROLE_ prefix convention, use .authorities("READ_REPORTS", "WRITE_REPORTS") instead.

Loading Users from a Database — The Real Pattern

In a production application your users live in a database table managed by JPA. You implement UserDetailsService and inject your Spring Data repository:

// 1 – Your JPA entity @Entity @Table(name = "app_users") public class AppUser { @Id @GeneratedValue(strategy = GenerationType.IDENTITY) private Long id; private String username; private String password; // BCrypt hash private boolean enabled; @ElementCollection(fetch = FetchType.EAGER) @CollectionTable(name = "user_roles", joinColumns = @JoinColumn(name = "user_id")) @Column(name = "role") private Set<String> roles = new HashSet<>(); // getters / setters omitted for brevity } // 2 – Spring Data repository public interface AppUserRepository extends JpaRepository<AppUser, Long> { Optional<AppUser> findByUsername(String username); } // 3 – Your UserDetailsService implementation @Service public class DatabaseUserDetailsService implements UserDetailsService { private final AppUserRepository userRepo; public DatabaseUserDetailsService(AppUserRepository userRepo) { this.userRepo = userRepo; } @Override public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException { AppUser user = userRepo.findByUsername(username) .orElseThrow(() -> new UsernameNotFoundException( "User not found: " + username)); List<GrantedAuthority> authorities = user.getRoles().stream() .map(role -> new SimpleGrantedAuthority("ROLE_" + role)) .collect(Collectors.toList()); return new org.springframework.security.core.userdetails.User( user.getUsername(), user.getPassword(), user.isEnabled(), true, true, true, // account non-expired, credentials non-expired, non-locked authorities); } }

Spring Security auto-detects a single UserDetailsService bean and wires it into the authentication provider. You do not need any extra configuration to connect the two.

Fetch roles eagerly or inside the transaction. loadUserByUsername is called inside Spring Security's authentication machinery, which is outside any open Hibernate session. If you load roles lazily you will hit a LazyInitializationException at runtime. Mark the roles collection FetchType.EAGER, or call Hibernate.initialize(user.getRoles()) while the transaction is still open, or use a JPQL JOIN FETCH query.

Combining UserDetailsService with SecurityFilterChain

You wire a custom UserDetailsService into your security configuration by injecting it and building an AuthenticationProvider:

@Configuration @EnableWebSecurity public class SecurityConfig { private final DatabaseUserDetailsService userDetailsService; private final PasswordEncoder passwordEncoder; public SecurityConfig(DatabaseUserDetailsService uds, PasswordEncoder pe) { this.userDetailsService = uds; this.passwordEncoder = pe; } @Bean public AuthenticationProvider authProvider() { DaoAuthenticationProvider provider = new DaoAuthenticationProvider(); provider.setUserDetailsService(userDetailsService); provider.setPasswordEncoder(passwordEncoder); return provider; } @Bean public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception { http .authenticationProvider(authProvider()) .authorizeHttpRequests(auth -> auth .requestMatchers("/public/**").permitAll() .anyRequest().authenticated() ) .formLogin(Customizer.withDefaults()); return http.build(); } }

DaoAuthenticationProvider is the standard implementation that calls loadUserByUsername, then verifies the submitted password against the stored hash using your PasswordEncoder. It also checks the four boolean flags on UserDetails before granting access — this is where you implement account locking, expiry, and disabling without writing any authentication logic yourself.

The UserDetails Flags and Why They Matter

  • isEnabled() — returns false to prevent login for soft-deleted or unverified accounts.
  • isAccountNonLocked() — returns false after too many failed login attempts.
  • isAccountNonExpired() — returns false for accounts whose subscription has lapsed.
  • isCredentialsNonExpired() — returns false to force a password reset after a set interval.

Each flag throws a different AuthenticationException subclass, so your error-handling code can distinguish between a locked account and an expired password and show the user an appropriate message.

Never leak existence information. When a username is not found, throw UsernameNotFoundException with a generic message ("Bad credentials", not "User alice does not exist"). Spring Security's DaoAuthenticationProvider masks UsernameNotFoundException by default (it re-throws it as a generic BadCredentialsException) to prevent user enumeration attacks. Do not disable this behaviour.

Distributed-Systems Consideration: Stateless Services

In a microservices architecture, calling loadUserByUsername — and hitting the database — on every request is expensive and creates a coupling between your service and the user store. The typical solution is to authenticate once (via a gateway or auth service), issue a signed token (JWT), and have downstream services validate the token locally without calling UserDetailsService at all. You will explore this pattern in Lesson 8 (Authorization Rules) and in the dedicated JWT lesson. For now, understand that UserDetailsService is the authentication-time hook; token validation replaces it in stateless flows.

Summary

UserDetailsService is the single extension point Spring Security gives you for loading user data. Implement it to integrate any backing store: InMemoryUserDetailsManager for tests, a JPA-backed service for production. Return a correctly populated UserDetails object — with hashed password, authorities, and accurate flag values — and wire it into a DaoAuthenticationProvider. From that point the framework handles password verification, flag checking, and authentication exception routing for you.