05-spring-security

The Filter Chain and SecurityContext

What FilterChainProxy actually does, the order filters run in, how the authenticated principal is stored per thread, and how to configure multiple chains.

October 9, 2026
spring-securityfilter-chainFilterChainProxySecurityContextSecurityFilterChainspring-boot

One Filter, Many Filters

Add spring-boot-starter-security and every endpoint requires authentication immediately. That happens because Spring Security registers a single servlet filter — springSecurityFilterChain, implemented by FilterChainProxy — which sits in front of DispatcherServlet and delegates to an ordered list of internal filters.

The order is the part worth internalising, because it explains several behaviours that otherwise look arbitrary.

CORS runs before CSRF and before authentication. A browser's preflight OPTIONS request carries no credentials, so if CORS ran after authentication every preflight would be rejected and no cross-origin request would ever succeed.

Authentication runs before authorisation. AuthorizationFilter is near the end, by which point the request either has an authenticated principal or doesn't.

ExceptionTranslationFilter sits between them. It catches AuthenticationException and AccessDeniedException from downstream filters and turns them into 401 and 403 responses. This is the filter that produces those statuses — which is why, as the orientation guide said, a @ControllerAdvice never sees them.

💡

/actuator/health responding without authentication while everything else 401s isn't a special case in the health endpoint — it's a requestMatchers(...).permitAll() rule evaluated by AuthorizationFilter. Every access decision in Spring Security happens in that one filter, driven by the rules you configure.

SecurityFilterChain

Configuration is a SecurityFilterChain bean built with the lambda DSL:

java
@Configuration
@EnableWebSecurity
public class SecurityConfig {
 
    @Bean
    SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
        return http
            .authorizeHttpRequests(auth -> auth
                .requestMatchers("/actuator/health/**", "/actuator/info").permitAll()
                .requestMatchers(HttpMethod.GET, "/api/products/**").permitAll()
                .requestMatchers("/api/admin/**").hasRole("ADMIN")
                .anyRequest().authenticated())
            .httpBasic(Customizer.withDefaults())
            .sessionManagement(s -> s.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
            .csrf(CsrfConfigurer::disable)
            .build();
    }
}

The old WebSecurityConfigurerAdapter was removed in Spring Security 6 — if you find it in a tutorial, the tutorial predates 2022.

Rule order decides everything

Rules are evaluated top to bottom and the first match wins. So this is wrong:

java
.requestMatchers("/api/**").authenticated()
.requestMatchers("/api/admin/**").hasRole("ADMIN")     // unreachable

/api/admin/orders matches the first rule and is granted to any authenticated user. The admin rule never runs. Order from most specific to least, and end with a catch-all:

java
.requestMatchers("/api/admin/**").hasRole("ADMIN")
.requestMatchers("/api/**").authenticated()
.anyRequest().denyAll()
🚨

Always end with anyRequest().authenticated() or denyAll(). A chain whose final rule is a permitAll() pattern leaves every unmatched path open — including endpoints added later by someone who didn't read this file. Deny by default means a new endpoint is secure until explicitly opened, which is the only ordering that fails safe.

hasRole vs hasAuthority

A persistent source of confusion:

java
.hasRole("ADMIN")            // requires the authority "ROLE_ADMIN"
.hasAuthority("ROLE_ADMIN")  // identical
.hasAuthority("orders:read") // no prefix added — use for fine-grained permissions

hasRole prepends ROLE_. So storing an authority literally named ADMIN and checking hasRole("ADMIN") fails, because it looks for ROLE_ADMIN. The convention: roles are coarse and prefixed; authorities are fine-grained and unprefixed. Pick one model per application and be consistent.

SecurityContext and the Principal

Once authenticated, the principal is stored in a SecurityContext, held by SecurityContextHolder in a ThreadLocal:

java
Authentication auth = SecurityContextHolder.getContext().getAuthentication();
String username = auth.getName();
Collection<? extends GrantedAuthority> authorities = auth.getAuthorities();

In application code, prefer injection to the static lookup:

java
@GetMapping("/me")
OrderSummary mine(@AuthenticationPrincipal UserDetails user) { }
 
@GetMapping("/me/jwt")
String subject(@AuthenticationPrincipal Jwt jwt) {
    return jwt.getSubject();
}

@AuthenticationPrincipal is a HandlerMethodArgumentResolver — the extensibility point from the Spring MVC guide. It's testable and self-documenting in a way SecurityContextHolder.getContext() buried in a service is not.

A ThreadLocal, with the usual consequence

Because the context lives in a ThreadLocal, it does not cross thread boundaries. Inside an @Async method or a scheduled job, getAuthentication() returns null. This is the context-propagation problem from the scheduling guide, and the fix is the same:

java
@Bean
DelegatingSecurityContextAsyncTaskExecutor securityAwareExecutor(ThreadPoolTaskExecutor delegate) {
    return new DelegatingSecurityContextAsyncTaskExecutor(delegate);
}

Spring Security also offers a strategy that propagates to child threads:

java
SecurityContextHolder.setStrategyName(SecurityContextHolder.MODE_INHERITABLETHREADLOCAL);
⚠️

MODE_INHERITABLETHREADLOCAL copies the context to threads created from the current one. With a thread pool that's a hazard rather than a help: pooled threads are created once and reused across many requests, so a thread may inherit a context from whichever request happened to create it and then serve a different user. Use DelegatingSecurityContext* wrappers, which set and clear the context around each task, rather than relying on inheritance.

The virtual-thread case is better behaved: each task gets a fresh virtual thread, so there is no reuse to leak across. Still prefer passing the principal explicitly into async work — an authorisation decision that depends on ambient thread state is one that can silently read the wrong state.

Multiple Filter Chains

An application often needs different rules for different paths — a token-authenticated API and a session-based admin UI. Define several chains, ordered:

java
@Bean
@Order(1)
SecurityFilterChain apiChain(HttpSecurity http) throws Exception {
    return http
        .securityMatcher("/api/**")                   // this chain handles only /api/**
        .authorizeHttpRequests(auth -> auth.anyRequest().authenticated())
        .oauth2ResourceServer(oauth -> oauth.jwt(Customizer.withDefaults()))
        .sessionManagement(s -> s.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
        .csrf(CsrfConfigurer::disable)                // safe: header-authenticated
        .build();
}
 
@Bean
@Order(2)
SecurityFilterChain webChain(HttpSecurity http) throws Exception {
    return http
        .authorizeHttpRequests(auth -> auth
            .requestMatchers("/login", "/css/**").permitAll()
            .anyRequest().hasRole("ADMIN"))
        .formLogin(form -> form.loginPage("/login"))
        .build();                                      // CSRF stays ON — session-based
}

Two rules for multiple chains. securityMatcher decides which chain handles a request, and the first matching chain is the only one that runs — chains don't compose. And the chain without a securityMatcher must be last, since it matches everything.

This pattern is also the clean answer to the CSRF question: disabled on the stateless API chain, enabled on the session-based web chain, each correct for its own authentication model rather than one global compromise.

Check yourself

An app defines two SecurityFilterChain beans: @Order(1) with securityMatcher('/api/**') requiring authentication, and @Order(2) with no matcher permitting all. A request to /api/orders arrives with no credentials. What happens?

Adding Your Own Filter

For a custom scheme, insert a filter at a defined position:

java
public class ApiKeyFilter extends OncePerRequestFilter {
 
    private final ApiKeyService apiKeys;
 
    @Override
    protected void doFilterInternal(HttpServletRequest request,
                                    HttpServletResponse response,
                                    FilterChain chain) throws ServletException, IOException {
        String key = request.getHeader("X-API-Key");
        if (key != null && SecurityContextHolder.getContext().getAuthentication() == null) {
            apiKeys.resolve(key).ifPresent(client -> {
                var auth = new PreAuthenticatedAuthenticationToken(
                        client.name(), null, client.authorities());
                SecurityContext context = SecurityContextHolder.createEmptyContext();
                context.setAuthentication(auth);
                SecurityContextHolder.setContext(context);
            });
        }
        chain.doFilter(request, response);
    }
}
java
http.addFilterBefore(new ApiKeyFilter(apiKeys), UsernamePasswordAuthenticationFilter.class);

Four details in that filter are load-bearing:

Extend OncePerRequestFilter. A plain Filter can run multiple times per request — on forwards, includes and async dispatches — which means re-authenticating and, worse, re-running side effects.

Create a new context rather than mutating the existing one. SecurityContextHolder.getContext().setAuthentication(...) can mutate a shared instance; createEmptyContext() then setContext() is the documented approach.

Always call chain.doFilter. Omit it and the request stops silently with an empty 200 — the filter equivalent of forgetting proceed() in @Around advice.

Don't reject unauthenticated requests here. Let the filter populate the context if it can and pass on; AuthorizationFilter makes the access decision. A filter that returns 401 itself bypasses your configured rules, so a permitAll() path would start failing.

✅

Before writing an authentication filter, check whether the mechanism already exists. JWT bearer tokens, OAuth2, OIDC, SAML, HTTP Basic, form login and X.509 are all built in and more carefully implemented than a first attempt will be. A custom filter is for a genuinely bespoke scheme — a legacy header, a signed-request format, an internal mTLS identity.

Debugging

When a request is rejected and the reason isn't obvious:

yaml
logging:
  level:
    org.springframework.security: DEBUG

This logs which chain matched, which filters ran, how authentication resolved, and which rule made the decision. It's the security equivalent of the auto-configuration condition report — the fastest route from "why is this a 403" to an answer.

For a structured view, @EnableWebSecurity(debug = true) prints the filter chain at startup, so you can see the actual order including any filters you added.

🚨

Neither belongs in production. DEBUG security logging includes tokens, headers and principal details, and the volume is high. Enable it in a dev profile.

The Mental Model, Restated

  1. One servlet filter, FilterChainProxy, delegating to an ordered internal chain that runs before DispatcherServlet.
  2. CORS → CSRF → authentication → ExceptionTranslationFilter → authorisation. The order explains preflights and where 401/403 come from.
  3. Rules match in order, first match wins. Most specific first; end with authenticated() or denyAll().
  4. hasRole("X") means the authority ROLE_X.
  5. The context is a ThreadLocal — absent in async and scheduled work; use DelegatingSecurityContext*, not MODE_INHERITABLETHREADLOCAL with pools.
  6. Multiple chains are alternatives, not layers. securityMatcher selects one; the unmatched chain goes last.
  7. Custom filters extend OncePerRequestFilter, populate the context, and always call chain.doFilter.

What's Next

The chain is now clear, and so is where authentication plugs into it. The next guide covers the mechanisms themselves: form login and HTTP Basic for the cases they suit, UserDetailsService for loading users from a database, and a JWT filter built from the pieces above — including why validating a token correctly is harder than it first appears.