Spring Security Architecture Deep Dive
How Spring Security actually works: the filter chain and its ordering, SecurityContextHolder, the AuthenticationManager delegation model, and where to plug in custom logic.
On this page
Spring Security has a reputation for being impenetrable, which comes almost entirely from people configuring it without knowing what the filter chain is. Once you can name the filters and say what each one does, the configuration DSL becomes readable.
Key Takeaways
- Spring Security is a chain of servlet filters, inserted into the container by one
DelegatingFilterProxy. - Filters run before the
DispatcherServlet, so security errors bypass@ControllerAdvice. SecurityContextHolderis a ThreadLocal — it does not cross into async work by itself.AuthenticationManagerdelegates to a list of providers; the first that supports the token handles it.- With several
SecurityFilterChainbeans, only the first matching chain runs.
The chain
The filters that matter most, in order:
| Filter | Job |
|---|---|
CorsFilter | Applies CORS policy; must run early so preflights are answered |
CsrfFilter | Validates the CSRF token on state-changing methods |
LogoutFilter | Handles the logout URL before anything tries to authenticate |
UsernamePasswordAuthenticationFilter | Processes form logins |
BearerTokenAuthenticationFilter | Extracts and validates a JWT |
ExceptionTranslationFilter | Catches security exceptions and converts them to 401/403 |
AuthorizationFilter | The final gate — checks authorities against the rules |
ExceptionTranslationFilter is the one worth understanding. It wraps everything downstream in a
try/catch. An AuthenticationException means "we do not know who you are" and it invokes the
AuthenticationEntryPoint — typically a 401 with a WWW-Authenticate header. An
AccessDeniedException means "we know who you are and you may not do this" and it invokes the
AccessDeniedHandler, typically a 403.
Configuration
@Configuration
@EnableWebSecurity
@EnableMethodSecurity
public class SecurityConfig {
// Most specific first. FilterChainProxy uses the FIRST matching chain, not
// all of them, so an unordered catch-all would swallow everything.
@Bean
@Order(1)
SecurityFilterChain apiChain(HttpSecurity http) throws Exception {
http
.securityMatcher("/api/**")
// No cookies, no browser, no CSRF risk.
.csrf(AbstractHttpConfigurer::disable)
.sessionManagement(s -> s.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
.authorizeHttpRequests(auth -> auth
.requestMatchers(HttpMethod.GET, "/api/v1/public/**").permitAll()
.requestMatchers("/api/v1/admin/**").hasRole("ADMIN")
.anyRequest().authenticated())
.oauth2ResourceServer(oauth -> oauth.jwt(Customizer.withDefaults()))
.exceptionHandling(ex -> ex
.authenticationEntryPoint(problemDetailEntryPoint())
.accessDeniedHandler(problemDetailAccessDeniedHandler()));
return http.build();
}
@Bean
@Order(2)
SecurityFilterChain webChain(HttpSecurity http) throws Exception {
http
.authorizeHttpRequests(auth -> auth
.requestMatchers("/", "/login", "/css/**", "/js/**").permitAll()
.anyRequest().authenticated())
.formLogin(form -> form.loginPage("/login").defaultSuccessUrl("/dashboard"))
.logout(logout -> logout.logoutSuccessUrl("/").deleteCookies("JSESSIONID"))
.headers(headers -> headers
.contentSecurityPolicy(csp -> csp.policyDirectives(
"default-src 'self'; frame-ancestors 'none'; base-uri 'self'")));
return http.build();
}
}Splitting API and web concerns into separate chains is the pattern to reach for early. They want opposite settings — stateless versus session, CSRF off versus on, bearer token versus form login — and trying to express both in one chain produces configuration nobody can reason about.
Making security errors match your error contract
@Bean
AuthenticationEntryPoint problemDetailEntryPoint() {
return (request, response, ex) -> {
response.setStatus(HttpStatus.UNAUTHORIZED.value());
response.setContentType("application/problem+json");
response.setHeader(HttpHeaders.WWW_AUTHENTICATE, "Bearer");
response.getWriter().write("""
{"type":"https://api.acme.com/errors/unauthenticated",
"title":"Unauthenticated","status":401,
"detail":"A valid bearer token is required."}
""");
};
}Without this, an unauthenticated API call returns Spring's default HTML error page or an empty body, while every other error in your API returns RFC 7807 JSON. Clients then need two parsers.
The authentication model
Four interfaces make up the model, and knowing which to implement saves a great deal of guessing.
Authentication is both the request ("here is a username and password") and the result ("this is who
they are and what they may do"). The same object is passed in unauthenticated and returned
authenticated, which is initially confusing and turns out to be convenient.
AuthenticationManager — practically always ProviderManager — holds a list of
AuthenticationProviders and asks each whether it supports the token type. The first that does gets
to authenticate.
UserDetailsService loads a user by name. It returns UserDetails, not an authentication decision;
the provider compares the password.
GrantedAuthority is a permission string. The ROLE_ prefix is a convention hasRole("ADMIN")
expands to ROLE_ADMIN, while hasAuthority uses the string verbatim — the single most common
source of "why is my rule not matching".
@Service
public class DatabaseUserDetailsService implements UserDetailsService {
private final UserRepository users;
@Override
public UserDetails loadUserByUsername(String username) {
User user = users.findByEmailIgnoreCase(username)
// Never distinguish "no such user" from "wrong password" in the
// message that reaches the client — that is an enumeration oracle.
.orElseThrow(() -> new UsernameNotFoundException("bad credentials"));
return org.springframework.security.core.userdetails.User
.withUsername(user.email())
.password(user.passwordHash())
.authorities(user.roles().stream()
.map(role -> new SimpleGrantedAuthority("ROLE_" + role.name()))
.toList())
.accountLocked(user.lockedUntil() != null && user.lockedUntil().isAfter(Instant.now()))
.disabled(!user.enabled())
.build();
}
}SecurityContextHolder and threads
The context lives in a ThreadLocal, which is fine for a request thread and a problem everywhere
else:
@Bean
Executor securityAwareExecutor() {
var executor = new ThreadPoolTaskExecutor();
executor.setCorePoolSize(8);
executor.setQueueCapacity(200);
executor.initialize();
// Copies the SecurityContext into the worker thread and clears it after.
return new DelegatingSecurityContextExecutor(executor);
}The alternative, MODE_INHERITABLETHREADLOCAL, propagates to threads created from the request thread
— but pooled threads are created once at startup, so it does nothing for an executor. The delegating
wrapper is the reliable option.
The same gap exists for message listeners and scheduled jobs. Neither has an authenticated user
unless you set one explicitly, which is why a @PreAuthorize on a service method called from a Kafka
listener will simply deny.
Method security
@Service
public class DocumentService {
@PreAuthorize("hasRole('ADMIN') or #ownerId == authentication.name")
public List<Document> listFor(String ownerId) { }
@PostAuthorize("returnObject.ownerId == authentication.name")
public Document find(String id) { }
@PreAuthorize("hasAuthority('SCOPE_documents.write')")
public Document create(DocumentPayload payload) { }
}@PreAuthorize runs before the method and is what you want almost always. @PostAuthorize runs
after and inspects the return value — useful when ownership is only knowable once loaded, but note
that the method has already executed, so it must not have side effects.
Method security is proxy-based, which means it inherits the self-invocation limitation: calling a
@PreAuthorize method from inside the same class bypasses the check entirely.
What to take away
Learn the filter order and the two exception paths, split API and web into separate chains, and
remember that only the first matching chain runs. Implement UserDetailsService for your user store,
never leak whether a username exists, and propagate the security context explicitly into async work.
Frequently Asked Questions
Why does my @ControllerAdvice not catch authentication errors?
Why is SecurityContext empty in my @Async method?
How do multiple SecurityFilterChain beans work?
Related tutorials
- Password Management & EncodingStoring passwords properly: choosing between BCrypt, Argon2 and scrypt, DelegatingPasswordEncoder for zero-downtime migration, strength rules, and breached-password checks.
- HTTP Basic & Form-Based AuthenticationThe two classic authentication mechanisms: when Basic is appropriate, configuring form login properly, custom success and failure handlers, logout, and account lockout that is not a DoS.
- In-Memory & JDBC AuthenticationWhere user credentials live: in-memory users for tests, JdbcUserDetailsManager and its schema, writing a custom UserDetailsService, and seeding an initial administrator safely.
- JWT Authentication Deep DiveJWTs done safely: structure and claims, why RS256 beats HS256, key rotation with JWKS, the alg=none and key-confusion attacks, and how to revoke a stateless token.