Skip to main content
spring internals

Default Filters and Their Responsibilities

9 min read Chapter 33 of 78

Default Filters and Their Responsibilities

Every filter in the SecurityFilterChain has one job. Understanding what each filter does and when it fires is the difference between debugging a security issue in minutes versus days. This chapter walks through the default filter chain for the SaaS backend’s JWT-secured API, filter by filter, in execution order.

The Default Filter Chain for a JWT Resource Server

When you configure a SecurityFilterChain with .oauth2ResourceServer(oauth2 -> oauth2.jwt(...)), Spring Security registers these filters in this exact order:

@Bean
@Order(1)
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();
}

Resulting filter chain:

OrderFilterResponsibility
0DisableEncodeUrlFilterPrevents session ID from leaking into URLs
1WebAsyncManagerIntegrationFilterPropagates SecurityContext to async threads
2SecurityContextHolderFilterSets up and clears SecurityContext per request
3HeaderWriterFilterWrites security headers (X-Content-Type-Options, etc.)
4LogoutFilterHandles logout requests
5BearerTokenAuthenticationFilterExtracts JWT from Authorization header, authenticates
6RequestCacheAwareFilterRestores saved request after authentication redirect
7SecurityContextHolderAwareRequestFilterWraps request to support isUserInRole()
8SessionManagementFilterSession fixation protection and concurrent session control
9ExceptionTranslationFilterCatches security exceptions, converts to HTTP responses
10AuthorizationFilterChecks if the authenticated user is authorized

Note: CsrfFilter is absent because we called .csrf(csrf -> csrf.disable()) for the stateless API chain.

Filter-by-Filter Walkthrough

DisableEncodeUrlFilter

org.springframework.security.web.session.DisableEncodeUrlFilter

Wraps the HttpServletResponse to make encodeURL() and encodeRedirectURL() return the original URL without appending ;jsessionid=.... This prevents session IDs from leaking into URLs, which is both a security concern (session fixation via URL) and an SEO problem.

For the stateless SaaS API, this filter is a no-op in practice because there are no sessions. It still runs as a defense-in-depth measure.

WebAsyncManagerIntegrationFilter

org.springframework.security.web.context.request.async.WebAsyncManagerIntegrationFilter

Registers a SecurityContextCallableProcessingInterceptor with Spring’s WebAsyncManager. When a controller returns a Callable, the SecurityContext is propagated to the thread that executes the Callable. Without this filter, async controller methods would lose the authenticated user’s context.

SecurityContextHolderFilter

org.springframework.security.web.context.SecurityContextHolderFilter

This filter replaced SecurityContextPersistenceFilter in Spring Security 6. It loads the SecurityContext from the SecurityContextRepository at the start of the request and clears SecurityContextHolder at the end.

For the stateless API chain, the SecurityContextRepository is org.springframework.security.web.context.RequestAttributeSecurityContextRepository, which stores the context as a request attribute (not in a session). The BearerTokenAuthenticationFilter creates the SecurityContext from the JWT on every request.

Critical behavior: SecurityContextHolderFilter always clears the SecurityContextHolder in a finally block. This prevents context leakage between requests on the same thread (servlet containers reuse threads).

HeaderWriterFilter

org.springframework.security.web.header.HeaderWriterFilter

Writes security-related HTTP response headers. Default headers:

X-Content-Type-Options: nosniff
X-Frame-Options: DENY
Cache-Control: no-cache, no-store, max-age=0, must-revalidate
Pragma: no-cache
X-XSS-Protection: 0

These are written once per request, on the response as it flows back up the filter chain.

LogoutFilter

org.springframework.security.web.authentication.logout.LogoutFilter

Checks if the request matches the logout URL (default: POST /logout). If it matches, it delegates to LogoutHandler instances (clears session, clears cookies, clears SecurityContextHolder) and then redirects.

For the stateless API, this filter is a no-op for every request that is not POST /logout. It checks the URL, does not match, and passes the request to the next filter.

BearerTokenAuthenticationFilter

org.springframework.security.oauth2.server.resource.web.authentication.BearerTokenAuthenticationFilter

This filter only exists when .oauth2ResourceServer() is configured. It does three things:

  1. Extracts the bearer token from the Authorization: Bearer <token> header using org.springframework.security.oauth2.server.resource.web.BearerTokenResolver.
  2. Creates a BearerTokenAuthenticationToken and passes it to the AuthenticationManager.
  3. On success, stores the resulting Authentication in the SecurityContextHolder.

If no Authorization header is present, the filter does nothing and passes the request along. If the token is present but invalid, it throws org.springframework.security.oauth2.server.resource.InvalidBearerTokenException, which ExceptionTranslationFilter later converts to a 401 response with a WWW-Authenticate header.

RequestCacheAwareFilter

org.springframework.security.web.savedrequest.RequestCacheAwareFilter

After a successful authentication redirect, this filter checks if there is a saved request in the cache (typically the original URL before the redirect to the login page). If found, it wraps the current request to restore original parameters.

For the stateless API chain, this filter is effectively a no-op because there are no authentication redirects with JWT.

SecurityContextHolderAwareRequestFilter

org.springframework.security.web.servletapi.SecurityContextHolderAwareRequestFilter

Wraps the HttpServletRequest so that servlet API methods like getRemoteUser(), isUserInRole(), and getUserPrincipal() work with Spring Security’s Authentication object.

SessionManagementFilter

org.springframework.security.web.session.SessionManagementFilter

Handles session fixation attacks and concurrent session control. When SessionCreationPolicy.STATELESS is configured, this filter is mostly inert.

ExceptionTranslationFilter

org.springframework.security.web.access.ExceptionTranslationFilter

This filter does not execute logic on the way in. It wraps the remainder of the chain in a try-catch:

try {
    filterChain.doFilter(request, response);
} catch (AuthenticationException ex) {
    // Send 401, trigger authentication entry point
} catch (AccessDeniedException ex) {
    if (isAnonymous) {
        // Send 401, trigger authentication entry point
    } else {
        // Send 403
    }
}

For the JWT resource server, AuthenticationException results in a 401 with WWW-Authenticate: Bearer. AccessDeniedException results in a 403.

This filter must be positioned before AuthorizationFilter in the chain. If you add a custom filter after AuthorizationFilter that throws a security exception, ExceptionTranslationFilter will not catch it.

AuthorizationFilter

org.springframework.security.web.access.intercept.AuthorizationFilter

The last filter in the chain. It replaced FilterSecurityInterceptor in Spring Security 6. It checks the current Authentication against the authorization rules defined in .authorizeHttpRequests().

For .anyRequest().authenticated(), it verifies that Authentication.isAuthenticated() returns true and the authentication is not anonymous.

If authorization fails, it throws AccessDeniedException, which ExceptionTranslationFilter catches.

Diagnostic: Logging Filter Execution

Add a filter that logs each filter’s execution to trace the full chain:

public class FilterChainLoggingFilter extends OncePerRequestFilter {

    private static final Logger log =
        LoggerFactory.getLogger(FilterChainLoggingFilter.class);

    @Override
    protected void doFilterInternal(HttpServletRequest request,
            HttpServletResponse response, FilterChain filterChain)
            throws ServletException, IOException {
        String method = request.getMethod();
        String uri = request.getRequestURI();
        log.info(">>> Security filter chain START for {} {}", method, uri);

        long start = System.nanoTime();
        try {
            filterChain.doFilter(request, response);
        } finally {
            long elapsed = (System.nanoTime() - start) / 1_000_000;
            log.info("<<< Security filter chain END for {} {} [{}ms, status={}]",
                method, uri, elapsed, response.getStatus());
        }
    }
}

Register it as the first filter in the chain:

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

Combined with FilterChainProxy DEBUG logging, this gives you complete visibility into what runs and when.

The Failure Mode: Global CSRF Disable

// BROKEN: disabling CSRF globally across all SecurityFilterChain beans

@Configuration
@EnableWebSecurity
public class BrokenCsrfConfig {

    @Bean
    @Order(1)
    public SecurityFilterChain apiFilterChain(HttpSecurity http) throws Exception {
        return http
            .securityMatcher("/api/**")
            .authorizeHttpRequests(auth -> auth.anyRequest().authenticated())
            .oauth2ResourceServer(oauth2 -> oauth2.jwt(Customizer.withDefaults()))
            .csrf(csrf -> csrf.disable()) // Correct for stateless API
            .build();
    }

    @Bean
    @Order(2)
    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(csrf -> csrf.disable()) // BROKEN: admin panel uses forms with sessions
            .build();
    }
}

The admin panel uses session-based form login. Forms submit via POST. Without CSRF protection, an attacker can craft a page that submits a form to /admin/settings while an admin is logged in. The session cookie is sent automatically. The server cannot distinguish between the admin’s legitimate form submission and the attacker’s forged request.

The symptom: everything works. No errors. No warnings. The vulnerability is silent. You discover it during a penetration test or when an attacker exploits it.

The Correct Pattern: CSRF Per Chain

// CORRECT: CSRF disabled only for the stateless API, enabled for the admin panel

@Configuration
@EnableWebSecurity
public class CorrectCsrfConfig {

    @Bean
    @Order(1)
    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()) // Safe: stateless, no cookies, JWT auth
            .build();
    }

    @Bean
    @Order(2)
    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()) // CSRF enabled: forms, sessions, cookies
            .build();
    }
}

The reasoning:

  • API chain: Uses JWT bearer tokens. No cookies. No session. An attacker cannot forge a request because the browser does not attach the Authorization header automatically. CSRF protection is unnecessary and would require API clients to manage CSRF tokens.
  • Admin chain: Uses form login with sessions. The browser sends the session cookie on every request to /admin/**. CSRF protection is mandatory. The CsrfFilter validates the _csrf token on every state-changing request (POST, PUT, DELETE).

Each SecurityFilterChain is configured independently. CSRF configuration in one chain does not affect another. This is the whole point of multiple chains: different security postures for different parts of the application.

Spring Security 6 Filter Order Reference

The complete default ordering defined in org.springframework.security.config.annotation.web.builders.FilterOrderRegistration:

PositionFilter Class
100DisableEncodeUrlFilter
200ForceEagerSessionCreationFilter
300ChannelProcessingFilter
400WebAsyncManagerIntegrationFilter
500SecurityContextHolderFilter
600HeaderWriterFilter
700CorsFilter
800CsrfFilter
900LogoutFilter
1000OAuth2AuthorizationRequestRedirectFilter
1100Saml2WebSsoAuthenticationRequestFilter
1200X509AuthenticationFilter
1300AbstractPreAuthenticatedProcessingFilter
1400CasAuthenticationFilter
1500OAuth2LoginAuthenticationFilter
1600Saml2WebSsoAuthenticationFilter
1700UsernamePasswordAuthenticationFilter
1900OpenIDAuthenticationFilter
2000DefaultLoginPageGeneratingFilter
2100DefaultLogoutPageGeneratingFilter
2200ConcurrentSessionFilter
2300DigestAuthenticationFilter
2400BearerTokenAuthenticationFilter
2500BasicAuthenticationFilter
2600RequestCacheAwareFilter
2700SecurityContextHolderAwareRequestFilter
2800JaasApiIntegrationFilter
2900RememberMeAuthenticationFilter
3000AnonymousAuthenticationFilter
3100OAuth2AuthorizationCodeGrantFilter
3200SessionManagementFilter
3300ExceptionTranslationFilter
3400FilterSecurityInterceptor (legacy)
3500AuthorizationFilter
3600SwitchUserFilter

When you call addFilterBefore(myFilter, BearerTokenAuthenticationFilter.class), your filter gets position 2399. When you call addFilterAfter(myFilter, BearerTokenAuthenticationFilter.class), it gets position 2401. When you call addFilterAt(myFilter, BearerTokenAuthenticationFilter.class), it gets position 2400, but the original filter is not removed. Both run. This is rarely what you want.

Use this table when deciding where to insert custom filters. Place authentication-related filters between positions 1000 and 3000. Place authorization-related filters near 3500. Place request preprocessing (headers, tenant resolution) between 600 and 900.