Skip to main content
spring internals

SecurityFilterChain: Filter Registration, Order, and the Request Lifecycle Through the Security Layer

7 min read Chapter 31 of 78

SecurityFilterChain: Filter Registration, Order, and the Request Lifecycle Through the Security Layer

Spring Security does not protect your application with magic. It protects it with servlet filters. Specifically, an ordered list of jakarta.servlet.Filter instances registered as a org.springframework.security.web.SecurityFilterChain bean. Every HTTP request to your application passes through this chain. Understanding the chain is not optional. Every security bug you will encounter traces back to a filter doing something unexpected, or a filter missing entirely.

SecurityFilterChain request flow from DelegatingFilterProxy through FilterChainProxy to filter chains with their ordered filters

The Architecture: Three Layers of Indirection

The servlet container knows nothing about Spring beans. It knows about servlet filters. Spring Security bridges this gap with three components stacked on top of each other:

  1. org.springframework.web.filter.DelegatingFilterProxy sits in the servlet container’s filter chain. The servlet container calls it on every request. It does exactly one thing: looks up a Spring bean by name and delegates the request to it.

  2. org.springframework.security.web.FilterChainProxy is that Spring bean. Its bean name is springSecurityFilterChain. It holds a list of SecurityFilterChain instances and selects which one handles the current request.

  3. org.springframework.security.web.SecurityFilterChain is the actual ordered list of security filters. Each chain has a request matcher and a list of filters.

The request flow:

Servlet Container
  └─ DelegatingFilterProxy (servlet filter)
       └─ FilterChainProxy (Spring bean: "springSecurityFilterChain")
            ├─ SecurityFilterChain #0 (matches /api/**)  → [Filter, Filter, Filter, ...]
            ├─ SecurityFilterChain #1 (matches /admin/**) → [Filter, Filter, Filter, ...]
            └─ SecurityFilterChain #2 (matches /**)       → [Filter, Filter, Filter, ...]

FilterChainProxy iterates through its chains in order. The first chain whose matcher accepts the request wins. The remaining chains are ignored. This is first-match semantics, and it is the single most common source of misconfiguration.

What HttpSecurity Builds

When you write a @Bean method returning SecurityFilterChain, Spring Boot’s auto-configuration calls org.springframework.security.config.annotation.web.builders.HttpSecurity to construct the filter list. Each method call on HttpSecurity adds or configures a filter:

HttpSecurity methodFilter added
.securityContext()SecurityContextHolderFilter
.csrf()CsrfFilter
.exceptionHandling()ExceptionTranslationFilter
.authorizeHttpRequests()AuthorizationFilter
.oauth2ResourceServer()BearerTokenAuthenticationFilter
.formLogin()UsernamePasswordAuthenticationFilter
.logout()LogoutFilter
.sessionManagement()SessionManagementFilter
.headers()HeaderWriterFilter
.requestCache()RequestCacheAwareFilter

Spring Security defines a strict ordering for these filters. You cannot change the relative order of built-in filters. You can insert custom filters before, after, or at the position of a built-in filter, but the default order is fixed in org.springframework.security.config.annotation.web.builders.FilterOrderRegistration.

Inspecting the Filter Chain

Programmatic Inspection

Inject the SecurityFilterChain beans and print them at startup:

@Component
public class SecurityFilterLogger implements CommandLineRunner {

    private final List<SecurityFilterChain> filterChains;

    public SecurityFilterLogger(List<SecurityFilterChain> filterChains) {
        this.filterChains = filterChains;
    }

    @Override
    public void run(String... args) {
        for (int i = 0; i < filterChains.size(); i++) {
            SecurityFilterChain chain = filterChains.get(i);
            System.out.println("SecurityFilterChain #" + i);
            List<Filter> filters = chain.getFilters();
            for (int j = 0; j < filters.size(); j++) {
                System.out.println("  [" + j + "] " + filters.get(j).getClass().getName());
            }
        }
    }
}

Output for a typical Spring Boot 3 application with JWT resource server:

SecurityFilterChain #0
  [0] org.springframework.security.web.session.DisableEncodeUrlFilter
  [1] org.springframework.security.web.context.request.async.WebAsyncManagerIntegrationFilter
  [2] org.springframework.security.web.context.SecurityContextHolderFilter
  [3] org.springframework.security.web.header.HeaderWriterFilter
  [4] org.springframework.security.web.csrf.CsrfFilter
  [5] org.springframework.security.web.authentication.logout.LogoutFilter
  [6] org.springframework.security.oauth2.server.resource.web.authentication.BearerTokenAuthenticationFilter
  [7] org.springframework.security.web.savedrequest.RequestCacheAwareFilter
  [8] org.springframework.security.web.servletapi.SecurityContextHolderAwareRequestFilter
  [9] org.springframework.security.web.session.SessionManagementFilter
  [10] org.springframework.security.web.access.ExceptionTranslationFilter
  [11] org.springframework.security.web.access.intercept.AuthorizationFilter

Actuator Inspection

With spring-boot-starter-actuator and management.endpoints.web.exposure.include=mappings, hit GET /actuator/mappings. The response includes a servletFilters section listing every filter in registration order. For a quicker view, enable the filterChains section:

management:
  endpoints:
    web:
      exposure:
        include: mappings, health
  endpoint:
    mappings:
      enabled: true

Debug Logging

Set logging.level.org.springframework.security.web.FilterChainProxy=DEBUG to see which chain is selected and which filters execute per request:

DEBUG FilterChainProxy - Securing GET /api/tenants
DEBUG FilterChainProxy - Matched SecurityFilterChain [0] with 12 filters
DEBUG FilterChainProxy - /api/tenants at position 1 of 12 in additional filter chain; firing Filter: 'DisableEncodeUrlFilter'
...

Multiple SecurityFilterChain Beans for the SaaS Backend

A multi-tenant SaaS backend needs different security configurations for different URL patterns. The API endpoints use JWT bearer tokens. The admin panel uses form-based login with sessions. Health check endpoints need no security at all.

@Configuration
@EnableWebSecurity
public class SecurityConfig {

    @Bean
    @Order(1)
    public SecurityFilterChain healthFilterChain(HttpSecurity http) throws Exception {
        return http
            .securityMatcher("/actuator/health", "/actuator/info")
            .authorizeHttpRequests(auth -> auth.anyRequest().permitAll())
            .csrf(csrf -> csrf.disable())
            .build();
    }

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

    @Bean
    @Order(3)
    public SecurityFilterChain adminFilterChain(HttpSecurity http) throws Exception {
        return http
            .securityMatcher("/admin/**")
            .authorizeHttpRequests(auth -> auth.anyRequest().hasRole("ADMIN"))
            .formLogin(form -> form.loginPage("/admin/login").permitAll())
            .csrf(Customizer.withDefaults())
            .build();
    }
}

Key points:

  • @Order(1) runs first. The health chain matches narrow paths and permits all.
  • @Order(2) matches /api/**. JWT authentication, stateless, no CSRF (tokens replace CSRF protection for stateless APIs).
  • @Order(3) matches /admin/**. Form login, session-based, CSRF enabled (form submissions need CSRF protection).
  • Each chain is independent. The API chain has BearerTokenAuthenticationFilter. The admin chain has UsernamePasswordAuthenticationFilter. They do not share filters.

The Failure Mode: Overlapping Matchers

// BROKEN: the default chain matches everything, so the API chain never fires

@Configuration
@EnableWebSecurity
public class BrokenSecurityConfig {

    @Bean
    @Order(1)
    public SecurityFilterChain defaultChain(HttpSecurity http) throws Exception {
        // This matches ALL requests because there is no securityMatcher.
        // It runs first because of @Order(1).
        return http
            .authorizeHttpRequests(auth -> auth.anyRequest().authenticated())
            .formLogin(Customizer.withDefaults())
            .build();
    }

    @Bean
    @Order(2)
    public SecurityFilterChain apiChain(HttpSecurity http) throws Exception {
        // This chain NEVER executes. The default chain above already
        // matched /api/** requests. FilterChainProxy uses first-match.
        return http
            .securityMatcher("/api/**")
            .authorizeHttpRequests(auth -> auth.anyRequest().authenticated())
            .oauth2ResourceServer(oauth2 -> oauth2.jwt(Customizer.withDefaults()))
            .csrf(csrf -> csrf.disable())
            .build();
    }
}

The symptom: API clients send a valid JWT bearer token but receive a 302 redirect to /login. The form login filter in the default chain handles the request before the API chain has a chance. Debug logging confirms: Matched SecurityFilterChain [0] for every request.

The Correct Pattern: Specific Matchers First

// CORRECT: specific matchers ordered before the catch-all

@Configuration
@EnableWebSecurity
public class CorrectSecurityConfig {

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

    @Bean
    @Order(2)
    public SecurityFilterChain adminChain(HttpSecurity http) throws Exception {
        return http
            .securityMatcher("/admin/**")
            .authorizeHttpRequests(auth -> auth.anyRequest().hasRole("ADMIN"))
            .formLogin(form -> form.loginPage("/admin/login").permitAll())
            .csrf(Customizer.withDefaults())
            .build();
    }

    @Bean
    @Order(3)
    public SecurityFilterChain defaultChain(HttpSecurity http) throws Exception {
        // Catch-all chain last. No securityMatcher means it matches everything
        // that was not matched by the previous chains.
        return http
            .authorizeHttpRequests(auth -> auth
                .requestMatchers("/", "/css/**", "/js/**").permitAll()
                .anyRequest().authenticated())
            .formLogin(Customizer.withDefaults())
            .build();
    }
}

Rules:

  1. Every SecurityFilterChain except the last must have a securityMatcher.
  2. Narrower matchers get lower @Order values (run first).
  3. The catch-all chain (no matcher) is always last.
  4. If two chains have the same @Order, the behavior is undefined. Always assign explicit values.

The securityMatcher Method

securityMatcher() accepts multiple patterns. Internally, it creates an org.springframework.security.web.util.matcher.OrRequestMatcher wrapping AntPathRequestMatcher instances (default) or MvcRequestMatcher instances (when spring-webmvc is on the classpath).

// Matches /api/v1/**, /api/v2/**, and /webhooks/**
http.securityMatcher("/api/v1/**", "/api/v2/**", "/webhooks/**")

You can also pass a custom RequestMatcher:

http.securityMatcher(new RequestHeaderRequestMatcher("X-Internal", "true"))

This chain would only apply to requests with the X-Internal: true header. Useful for internal service-to-service communication in the SaaS backend that should bypass normal authentication.

Summary

SecurityFilterChain is a list. FilterChainProxy picks the first matching list. Filters within the list execute in a fixed order. Misconfiguration almost always means either the wrong chain was selected (matcher overlap) or a filter is missing (forgot to call the corresponding HttpSecurity method). Debug it by logging FilterChainProxy at DEBUG level and printing the filter list at startup. That covers 90% of Spring Security debugging.