The Spring Security Filter Chain, Explained

5 min read

  • Spring Boot
  • Security
  • Java

Spring Security has a reputation for being magic. You add the starter, every endpoint starts returning 401, and a generated password appears in the logs. Nothing in your code changed.

It is not magic. It is a list of servlet filters, and once you can name them the whole framework stops being mysterious.

The code here targets Spring Security 7.1, the current GA line, which is what you get with Spring Boot 4.

Three layers, not one

There are three distinct objects between Tomcat and your controller, and people conflate them constantly.

DelegatingFilterProxy is a plain servlet filter that Spring Boot registers with the container. It does almost nothing: it looks up a Spring bean named springSecurityFilterChain and hands the request to it. It exists because the servlet container builds its filters before the application context exists, and a container-managed filter cannot be autowired.

FilterChainProxy is that bean. It holds a list of SecurityFilterChain instances and picks exactly one of them per request. The first chain whose matcher accepts the request wins, and the rest are never consulted.

SecurityFilterChain is a matcher plus an ordered list of security filters. This is the object you define as a bean.

So a request enters a container filter, gets routed by a proxy, and runs through the filters of a single chain. Every 401, every redirect to a login page, every CSRF rejection comes out of one of those filters.

The filters that ship by default

A default web application gets somewhere around fifteen filters. The ones worth knowing, roughly in order:

Filter What it does
SecurityContextHolderFilter Loads the stored SecurityContext into the SecurityContextHolder, and clears it on the way out
HeaderWriterFilter Writes the security response headers
CsrfFilter Rejects unsafe methods without a valid CSRF token
LogoutFilter Handles the logout URL
UsernamePasswordAuthenticationFilter Processes a form login POST
BasicAuthenticationFilter Processes an Authorization: Basic header
AnonymousAuthenticationFilter Substitutes an anonymous token when nothing authenticated
ExceptionTranslationFilter Turns thrown security exceptions into a 401, 403 or login redirect
AuthorizationFilter Applies your access rules, and throws if they fail

Two of those pairings explain most confusing behaviour.

ExceptionTranslationFilter sits before AuthorizationFilter in the list, which means it wraps it on the way back up the stack. Authorization failures are thrown, not returned, and the filter above catches them. That is why an AccessDeniedException from a @PreAuthorize deep inside a service still produces a clean 403: it propagates up through the filters until something catches it.

AnonymousAuthenticationFilter is why authentication is rarely null. An unauthenticated request gets an AnonymousAuthenticationToken rather than nothing at all, so authorization rules can be written uniformly.

Also note the name SecurityContextHolderFilter. Its predecessor, SecurityContextPersistenceFilter, saved the context to the session automatically at the end of the request. The current filter does not. If you authenticate a user manually you have to save the context yourself, via a SecurityContextRepository. Silent session loss after a custom login is almost always this.

Defining a chain

One SecurityFilterChain bean, one lambda per concern:

@Configuration
@EnableWebSecurity
public class SecurityConfig {

    @Bean
    SecurityFilterChain app(HttpSecurity http) throws Exception {
        return http
            .authorizeHttpRequests(auth -> auth
                .requestMatchers("/", "/blog/**").permitAll()
                .requestMatchers("/admin/**").hasRole("ADMIN")
                .anyRequest().authenticated())
            .formLogin(Customizer.withDefaults())
            .logout(Customizer.withDefaults())
            .build();
    }
}

If you are coming from older examples, two things are gone for good in Spring Security 7. The and() chaining style no longer compiles, so the lambda DSL is the only DSL. And antMatchers and mvcMatchers are gone: requestMatchers now parses patterns with PathPatternRequestMatcher, the same engine Spring MVC uses for routing.

Order matters inside authorizeHttpRequests. Rules are evaluated top to bottom and the first match wins, so anyRequest() belongs last. Put it first and everything below it is dead code.

More than one chain

This is where the "first match wins" rule bites. Say you want a stateless token API alongside a session based UI:

@Bean
@Order(1)
SecurityFilterChain api(HttpSecurity http) throws Exception {
    return http
        .securityMatcher("/api/**")
        .authorizeHttpRequests(auth -> auth.anyRequest().hasAuthority("SCOPE_api"))
        .csrf(AbstractHttpConfigurer::disable)
        .sessionManagement(session ->
            session.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
        .oauth2ResourceServer(oauth2 -> oauth2.jwt(Customizer.withDefaults()))
        .build();
}

@Bean
@Order(2)
SecurityFilterChain ui(HttpSecurity http) throws Exception {
    return http
        .authorizeHttpRequests(auth -> auth.anyRequest().authenticated())
        .formLogin(Customizer.withDefaults())
        .build();
}

securityMatcher decides which requests the chain claims. authorizeHttpRequests decides what happens to requests it has already claimed. Mixing those up is the single most common configuration bug in Spring Security.

Two rules follow from it. Every chain except the last one needs a securityMatcher, because a chain without one matches everything. And the chains need explicit @Order values, because otherwise the bean ordering is whatever the context happens to produce, and a catch-all chain that lands at position one swallows the whole application.

Note that CSRF is disabled on the API chain only. Disabling it globally because "the API does not need it" also disables it for the browser facing form, which is exactly the thing it protects.

Adding your own filter

A custom filter should extend OncePerRequestFilter, which guards against running twice on a forward or an async dispatch:

public class ApiKeyFilter extends OncePerRequestFilter {

    @Override
    protected void doFilterInternal(HttpServletRequest request,
                                    HttpServletResponse response,
                                    FilterChain chain)
            throws ServletException, IOException {

        String key = request.getHeader("X-Api-Key");
        if (key != null) {
            Authentication auth = keys.authenticate(key);
            SecurityContext context = SecurityContextHolder.createEmptyContext();
            context.setAuthentication(auth);
            SecurityContextHolder.setContext(context);
        }
        chain.doFilter(request, response);
    }
}

Register it at a position, never just "somewhere":

http.addFilterBefore(new ApiKeyFilter(), AuthorizationFilter.class);

Two details in that filter are deliberate. It calls chain.doFilter even when there is no key, because rejecting the request is AuthorizationFilter's job, not yours. And it builds a fresh context rather than mutating the existing one, which avoids writing into a context object shared across threads.

If you register the filter as a @Bean, Spring Boot will also auto register it with the servlet container, so it runs twice: once outside the security chain and once inside. Wrap it in a FilterRegistrationBean with setEnabled(false), or do not make it a bean at all.

When it still does not behave

Turn the chain on in the logs:

logging.level.org.springframework.security=DEBUG

You get a line per filter per request, showing which chain matched and which filter ended the request. Ninety percent of the time the answer is visible immediately: a chain you did not expect claimed the request, or a filter you assumed was present was never in the list.

The filter chain rewards being read rather than guessed at. It is an ordered list of small, single purpose objects, and the configuration DSL is just a builder for that list.