Default Filters and Their Responsibilities
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:
| Order | Filter | Responsibility |
|---|---|---|
| 0 | DisableEncodeUrlFilter | Prevents session ID from leaking into URLs |
| 1 | WebAsyncManagerIntegrationFilter | Propagates SecurityContext to async threads |
| 2 | SecurityContextHolderFilter | Sets up and clears SecurityContext per request |
| 3 | HeaderWriterFilter | Writes security headers (X-Content-Type-Options, etc.) |
| 4 | LogoutFilter | Handles logout requests |
| 5 | BearerTokenAuthenticationFilter | Extracts JWT from Authorization header, authenticates |
| 6 | RequestCacheAwareFilter | Restores saved request after authentication redirect |
| 7 | SecurityContextHolderAwareRequestFilter | Wraps request to support isUserInRole() |
| 8 | SessionManagementFilter | Session fixation protection and concurrent session control |
| 9 | ExceptionTranslationFilter | Catches security exceptions, converts to HTTP responses |
| 10 | AuthorizationFilter | Checks 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:
- Extracts the bearer token from the
Authorization: Bearer <token>header usingorg.springframework.security.oauth2.server.resource.web.BearerTokenResolver. - Creates a
BearerTokenAuthenticationTokenand passes it to theAuthenticationManager. - On success, stores the resulting
Authenticationin theSecurityContextHolder.
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
Authorizationheader 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. TheCsrfFiltervalidates the_csrftoken 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:
| Position | Filter Class |
|---|---|
| 100 | DisableEncodeUrlFilter |
| 200 | ForceEagerSessionCreationFilter |
| 300 | ChannelProcessingFilter |
| 400 | WebAsyncManagerIntegrationFilter |
| 500 | SecurityContextHolderFilter |
| 600 | HeaderWriterFilter |
| 700 | CorsFilter |
| 800 | CsrfFilter |
| 900 | LogoutFilter |
| 1000 | OAuth2AuthorizationRequestRedirectFilter |
| 1100 | Saml2WebSsoAuthenticationRequestFilter |
| 1200 | X509AuthenticationFilter |
| 1300 | AbstractPreAuthenticatedProcessingFilter |
| 1400 | CasAuthenticationFilter |
| 1500 | OAuth2LoginAuthenticationFilter |
| 1600 | Saml2WebSsoAuthenticationFilter |
| 1700 | UsernamePasswordAuthenticationFilter |
| 1900 | OpenIDAuthenticationFilter |
| 2000 | DefaultLoginPageGeneratingFilter |
| 2100 | DefaultLogoutPageGeneratingFilter |
| 2200 | ConcurrentSessionFilter |
| 2300 | DigestAuthenticationFilter |
| 2400 | BearerTokenAuthenticationFilter |
| 2500 | BasicAuthenticationFilter |
| 2600 | RequestCacheAwareFilter |
| 2700 | SecurityContextHolderAwareRequestFilter |
| 2800 | JaasApiIntegrationFilter |
| 2900 | RememberMeAuthenticationFilter |
| 3000 | AnonymousAuthenticationFilter |
| 3100 | OAuth2AuthorizationCodeGrantFilter |
| 3200 | SessionManagementFilter |
| 3300 | ExceptionTranslationFilter |
| 3400 | FilterSecurityInterceptor (legacy) |
| 3500 | AuthorizationFilter |
| 3600 | SwitchUserFilter |
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.