Skip to content
ExponaBLOG
JAVA, SPRING FRAMEWORK & SPRING BOOT · DAY 053 / 100

Spring Security filter chain explained, explained simply

Every request passes through a chain of filters before your controller.

Expona Admin6 min read

Every HTTP request that hits your Spring Boot application flows through a series of security checks before it ever reaches your controller. Understanding the Spring Security filter chain—how it works, why order matters, and how to configure it—is crucial for building production-grade applications. The Spring Security filter chain explained, explained simply, comes down to this: a request enters a sequence of filters, each one verifying and modifying the security context, until your business logic finally runs.

The flow: Order matters

Think of the Spring Security filter chain as a security checkpoint at an airport. Each filter is a station: bag screening, ID check, boarding pass verification. If you fail at any station, you don't move forward. If you pass, the next station gets you. The order is fixed and intentional.

Here's the typical journey a request takes through the Spring Security filter chain:

  1. Request enters SecurityFilterChain: Spring's DispatcherServlet hands off to the filter chain before routing to handlers.
  2. CORS and CSRF filters: Cross-origin requests are validated; state-changing operations (POST, PUT, DELETE) check for CSRF tokens.
  3. Authentication filter (JWT, session, or other): The request is examined for credentials—JWT tokens in headers, session cookies, Basic Auth, OAuth2 tokens.
  4. Authorization checks: Assuming authentication succeeded, role-based or permission-based rules are evaluated.
  5. Controller runs: If all checks pass, your business logic executes with a populated SecurityContext.

If any filter rejects the request, the chain stops there. No controller execution. The response is sent back (typically a 401 Unauthorized or 403 Forbidden).

Real-world scenario: A mobile app sends a JWT in an Authorization header. The CORS filter checks if the origin is allowed. Then the JWT authentication filter decodes the token, loads the user's authorities, and puts them in the SecurityContext. Finally, a method-level @PreAuthorize("hasRole('ADMIN')") gate checks permissions. If all three pass, your endpoint logic runs.

One SecurityFilterChain bean: Configure with the lambda DSL

Modern Spring Security (6.0+) moved away from WebSecurityConfigurerAdapter, which was verbose and error-prone. Today, we create a SecurityFilterChain bean using the lambda DSL, which is cleaner and encourages composability.

Here's a practical setup for a typical web application with JWT authentication:

java
@Configuration
@EnableWebSecurity
public class SecurityConfig {

  @Bean
  public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
    http
      .csrf(csrf -> csrf.disable()) // Disable for stateless APIs
      .cors(withDefaults())          // Enable CORS with defaults
      .authorizeHttpRequests(authz -> authz
        .requestMatchers("/auth/login", "/auth/register").permitAll()
        .requestMatchers("/admin/**").hasRole("ADMIN")
        .anyRequest().authenticated()
      )
      .sessionManagement(session -> session
        .sessionCreationPolicy(SessionCreationPolicy.STATELESS) // JWT is stateless
      )
      .addFilterBefore(new JwtAuthenticationFilter(), UsernamePasswordAuthenticationFilter.class);

    return http.build();
  }

  @Bean
  public PasswordEncoder passwordEncoder() {
    return new BCryptPasswordEncoder();
  }
}

Notice the method chaining with lambda expressions. Each call (csrf, cors, authorizeHttpRequests, sessionManagement) configures a different aspect of the security filter chain. The addFilterBefore() method inserts our custom JwtAuthenticationFilter before the default UsernamePasswordAuthenticationFilter. This order is critical: we want JWT extraction to happen before standard form-based authentication attempts.

The sessionCreationPolicy(SessionCreationPolicy.STATELESS) tells Spring not to create HttpSession objects, which is essential for APIs that rely on tokens instead of cookies.

Multiple chains: Use securityMatcher to give /api and /admin different rules

Real applications rarely have one-size-fits-all security. Your public API might use JWT and be stateless, while your admin dashboard might use sessions and enforce stricter rate limiting. Spring Security supports multiple SecurityFilterChain beans, each with its own securityMatcher.

Here's an example:

java
@Configuration
@EnableWebSecurity
public class SecurityConfig {

  @Bean
  @Order(1)
  public SecurityFilterChain apiChain(HttpSecurity http) throws Exception {
    http
      .securityMatcher("/api/**")
      .csrf(csrf -> csrf.disable())
      .cors(withDefaults())
      .authorizeHttpRequests(authz -> authz
        .requestMatchers("/api/public/**").permitAll()
        .requestMatchers("/api/admin/**").hasRole("ADMIN")
        .anyRequest().authenticated()
      )
      .sessionManagement(session -> session
        .sessionCreationPolicy(SessionCreationPolicy.STATELESS)
      )
      .addFilterBefore(new JwtAuthenticationFilter(), UsernamePasswordAuthenticationFilter.class);

    return http.build();
  }

  @Bean
  @Order(2)
  public SecurityFilterChain adminChain(HttpSecurity http) throws Exception {
    http
      .securityMatcher("/admin/**")
      .csrf(csrf -> csrf.withDefaults()) // CSRF enabled for form submissions
      .authorizeHttpRequests(authz -> authz
        .requestMatchers("/admin/login").permitAll()
        .anyRequest().hasRole("ADMIN")
      )
      .sessionManagement(session -> session
        .sessionCreationPolicy(SessionCreationPolicy.IF_REQUIRED)
      )
      .formLogin(form -> form
        .loginPage("/admin/login")
        .defaultSuccessUrl("/admin/dashboard")
      );

    return http.build();
  }

  @Bean
  @Order(3)
  public SecurityFilterChain defaultChain(HttpSecurity http) throws Exception {
    http
      .authorizeHttpRequests(authz -> authz
        .anyRequest().permitAll()
      );

    return http.build();
  }
}

The @Order annotation determines which chain is evaluated first. Spring checks matchers in order: if a request matches /api/**, the apiChain handles it. If it matches /admin/**, the adminChain takes over. Everything else falls through to defaultChain.

This pattern is powerful for microservices, multi-tenant applications, or setups where you need strict rules for sensitive paths and looser rules for public content.

Debug it: Logging reveals every filter decision

When security behaves unexpectedly—requests are rejected when they shouldn't be, or allowed when they should be rejected—you need visibility into what each filter is doing. Spring Security's debug logging is invaluable.

Add this to your application.properties or application.yml:

java
logging.level.org.springframework.security=DEBUG

Restart your application and tail the logs. You'll see output like:

java
DEBUG org.springframework.security.web.FilterChainProxy : Trying to match request against DefaultSecurityFilterChain...
DEBUG org.springframework.security.web.FilterChainProxy : Trying to match using securityMatcher = Ant [pattern='/api/**']
DEBUG org.springframework.security.web.FilterChainProxy : Trying to match using requestMatchers = [POST /auth/login]
DEBUG org.springframework.security.authentication.UsernamePasswordAuthenticationFilter : Authentication attempt using org.springframework.security.authentication.UsernamePasswordAuthenticationFilter
DEBUG org.springframework.security.authentication.ProviderManager : Authentication attempt using org.springframework.security.authentication.dao.DaoAuthenticationProvider
DEBUG org.springframework.security.access.intercept.FilterSecurityInterceptor : Attempting authorization...

This tells you: which chain matched, which filters ran, whether authentication succeeded, and whether authorization checks passed. If a request is rejected, you'll see the specific filter and the reason. This is far faster than guessing.

For production, you'd typically set it to WARN or ERROR to avoid excessive logs, but DEBUG is your best friend during development and troubleshooting.

Common mistakes

  • Wrong filter order: Adding a custom filter after UsernamePasswordAuthenticationFilter when it should go before. If your JWT filter runs after username/password, the authentication context may already be populated incorrectly, or your credentials won't be extracted in time.
  • Forgetting to disable CSRF for stateless APIs: CSRF tokens are session-based. If you're using JWT and sessions are stateless, CSRF checks will fail on POST/PUT/DELETE requests and waste debugging time. Disable it with csrf.disable() for APIs.
  • Overlapping securityMatchers: If two chains both match the same path and both have @Order(1), behavior is unpredictable. Be explicit about order and make sure matchers don't overlap unless intentional.
  • Assuming all requests are stateless by default: Spring's default session policy can create sessions unexpectedly. Explicitly set SessionCreationPolicy.STATELESS if you need a stateless API.
  • Not testing authorization paths: It's easy to enable authentication but mess up role checks. Always test protected endpoints with different user roles to catch @PreAuthorize misconfigurations early.

Key takeaways

  • The Spring Security filter chain is a sequential pipeline: each request flows through CORS, CSRF, authentication, authorization, and finally your controller. Order matters and is intentional.
  • Configure one chain with @Bean SecurityFilterChain and the lambda DSL; WebSecurityConfigurerAdapter is deprecated. Use addFilterBefore() or addFilterAfter() to inject custom filters at the right moment.
  • Create multiple chains with @Order and securityMatcher() to apply different rules to different URL patterns—essential for APIs, admin dashboards, and public content coexisting in one app.
  • Enable DEBUG logging for org.springframework.security to see exactly which filter rejected a request and why. This beats guessing every time.
  • Stateless APIs should disable CSRF, enable CORS where needed, and use SessionCreationPolicy.STATELESS. Form-based apps should enable CSRF and allow session creation.
ShareXLinkedInReddit

Found this useful? Support the writing.

Buy me a coffee
Written by
Expona Admin
Site administrator

Comments

Loading comments…

Leave a comment

Comments are reviewed before they appear. Be kind and stay on topic.

Related articles