Storing passwords (BCrypt/Argon2, DelegatingPasswordEncoder), the security filter chain and custom filters, session management and concurrent-session control, debugging unexpected 403s, dynamic access-control policies, testing security, salting, AuthenticationManager vs ProviderManager, and custom access-denied handling.
Published September 25, 2026
The second half of Spring Security is about internals and operations: how authentication is actually performed, where filters sit, how sessions behave, and how to debug a baffling 403. These are exactly the things you only learn by running Spring Security in production.
Short answer: Never store passwords in plaintext, or reversibly. Store a hash from a slow, salted, adaptive algorithm: BCrypt (Spring's default), Argon2id, scrypt or PBKDF2. Use DelegatingPasswordEncoder, which prefixes each hash with its algorithm ({bcrypt}$2a$10$…). You can then upgrade algorithms later, and existing hashes keep working.
@Bean
PasswordEncoder passwordEncoder() {
return PasswordEncoderFactories.createDelegatingPasswordEncoder(); // {bcrypt} by default
}
// registration: user.setPasswordHash(encoder.encode(rawPassword));
// login: handled by DaoAuthenticationProvider → encoder.matches(raw, stored)
Key points to cover:
UserDetailsPasswordService, so Spring re-hashes passwords transparently at login when you raise the strength.Learn it in depth → Password Encoding
Short answer: Every request passes through DelegatingFilterProxy → FilterChainProxy, which picks the first matching SecurityFilterChain, and runs its ordered filters. Among them:
SecurityContextHolderFilter;ExceptionTranslationFilter (turns security exceptions into 401/403 responses or login redirects);AuthorizationFilter, last.To add your own filter, write a filter (usually OncePerRequestFilter), and place it relative to a known filter:
@Bean
SecurityFilterChain api(HttpSecurity http, ApiKeyFilter apiKeyFilter) throws Exception {
return http
.addFilterBefore(apiKeyFilter, UsernamePasswordAuthenticationFilter.class) // or addFilterAfter / addFilterAt
.authorizeHttpRequests(a -> a.anyRequest().authenticated())
.build();
}
Common trap: annotating the custom filter as a @Component and adding it to the chain. Spring Boot then also registers it as a regular servlet filter, so it runs twice. Declare it with a FilterRegistrationBean that has setEnabled(false), or don't make it a bean.
Learn it in depth → Authentication Mechanics
Short answer:
ALWAYS, IF_REQUIRED (the default), NEVER, or STATELESS (for token APIs).changeSessionId, the default).maximumSessions(n), with either maxSessionsPreventsLogin(true) (block new logins) or the default behaviour (expire the oldest session).http.sessionManagement(s -> s
.sessionFixation(f -> f.changeSessionId())
.maximumSessions(1)
.maxSessionsPreventsLogin(false) // the new login kicks out the old session
.expiredUrl("/login?expired"));
Key points to cover:
SessionRegistry. In a cluster, use Spring Session (for example FindByIndexNameSessionRepository with Redis), so the limits apply across instances.Short answer: Work through it systematically:
logging.level.org.springframework.security=TRACE. It logs which SecurityFilterChain matched, each filter, and why authorisation failed.authentication.getAuthorities(). Common causes:
ROLE_ prefix (hasRole('ADMIN') vs the authority ADMIN);SCOPE_x.securityMatcher).@PreAuthorize expressions, and proxies (self-invocation).@WithMockUser, jwt()).Short answer: When the rules live in data (a permissions table, per-tenant policies, or feature entitlements) rather than code:
AuthorizationManager<RequestAuthorizationContext> for URL rules, or AuthorizationManager<MethodInvocation> for methods, which loads the policies (cached) and decides.@PreAuthorize("@policy.can(authentication, 'invoice:approve', #invoiceId)")).@Bean
SecurityFilterChain api(HttpSecurity http, DynamicUrlAuthorizationManager dynamicRules) throws Exception {
return http.authorizeHttpRequests(a -> a
.requestMatchers("/api/public/**").permitAll()
.anyRequest().access(dynamicRules)) // rules loaded from the DB, and cached
.build();
}
Key points to cover:
Short answer: With spring-security-test, combined with @WebMvcTest or @SpringBootTest:
@WithMockUser(roles = "ADMIN"), @WithAnonymousUser and @WithUserDetails, for method or MVC tests..with(jwt().authorities(...)), .with(csrf()), .with(user("u").roles("USER")).@WebMvcTest(AdminController.class)
@Import(SecurityConfig.class)
class AdminSecurityTest {
@Autowired MockMvc mvc;
@Test void anonymousIs401() throws Exception { mvc.perform(get("/api/admin/stats")).andExpect(status().isUnauthorized()); }
@Test void userIs403() throws Exception {
mvc.perform(get("/api/admin/stats").with(jwt().authorities(new SimpleGrantedAuthority("ROLE_USER"))))
.andExpect(status().isForbidden());
}
@Test void adminIs200() throws Exception {
mvc.perform(get("/api/admin/stats").with(jwt().authorities(new SimpleGrantedAuthority("ROLE_ADMIN"))))
.andExpect(status().isOk());
}
}
Short answer: A salt is a random value generated per password, and combined with it before hashing. Identical passwords then get different hashes, and precomputed rainbow tables become useless. Attackers have to crack each hash separately.
Key points to cover:
matches() extracts it again to verify.Short answer: Here's a realistic banking rule, with a limit that depends on the caller's role:
@PreAuthorize("""
hasRole('TELLER') and #amount <= 50000
or hasRole('BRANCH_MANAGER') and #amount <= 1000000
or hasRole('TREASURY')""")
public Transfer approveTransfer(long transferId, BigDecimal amount) { … }
Key points to cover:
AuthenticationManager and ProviderManager?Short answer: AuthenticationManager is the interface with a single method, authenticate(Authentication), which returns a fully authenticated token or throws AuthenticationException. ProviderManager is its main implementation. It holds a list of AuthenticationProviders, and asks each one that supports the token type:
DaoAuthenticationProvider for username and password, through UserDetailsService + PasswordEncoder;JwtAuthenticationProvider for bearer tokens;The first provider to succeed wins. If none can handle the token, it delegates to an optional parent manager.
@Bean
AuthenticationManager authenticationManager(UserDetailsService uds, PasswordEncoder encoder) {
var dao = new DaoAuthenticationProvider(encoder);
dao.setUserDetailsService(uds);
return new ProviderManager(dao); // add more providers, e.g. an LDAP one, for multiple login methods
}
Short answer: Configure the ExceptionTranslationFilter handlers:
AccessDeniedHandler, or simply accessDeniedPage("/access-denied").AuthenticationEntryPoint. It redirects to the login page for web apps, or returns a 401 JSON Problem Detail for APIs.http.exceptionHandling(e -> e
.accessDeniedPage("/access-denied") // web app: a friendly 403 page
.defaultAuthenticationEntryPointFor(
new HttpStatusEntryPoint(HttpStatus.UNAUTHORIZED),
PathPatternRequestMatcher.withDefaults().matcher("/api/**"))); // APIs: plain 401, no redirect
Key points to cover:
AccessDeniedHandler, rather than redirecting.Q: Why is BCrypt slow on purpose? A: Slowness limits how many guesses per second an attacker can make against stolen hashes. A normal login barely notices 100 ms, but an offline attacker's cost goes up enormously.
Q: Can one application have several SecurityFilterChains?
A: Yes. Order them with @Order, and scope each with securityMatcher(...). For example, one chain for /api/** (stateless JWT), and another for the web UI (form login, sessions).
Q: How do you log out a JWT-authenticated user? A: The client discards the tokens, and the identity provider revokes the refresh token. Access tokens stay valid until they expire, which is why they're kept short-lived. Use a denylist if you need immediate revocation.
Q: What's ExceptionTranslationFilter responsible for?
A: It catches AuthenticationException (starting authentication through the entry point: a 401 or a login redirect) and AccessDeniedException (a 403 through the access-denied handler), for exceptions thrown further down the chain.