Spring Security Fundamentals

The Security Filter Chain

18 min Lesson 2 of 13

The Security Filter Chain

Every HTTP request that reaches a Spring Boot application passes through a pipeline of servlet filters before it ever touches your controller. Spring Security plugs into this pipeline with its own ordered chain of filters — the Security Filter Chain. Understanding that chain is essential: it determines what happens to unauthenticated requests, how credentials are extracted, how sessions are managed, and what error responses are sent. If something security-related behaves unexpectedly, the filter chain is almost always where you need to look.

How Servlet Filters Work

The Java Servlet specification defines a Filter interface with one method: doFilter(request, response, chain). A filter can inspect or modify the request, call chain.doFilter() to pass control to the next filter in line, and then inspect or modify the response on the way back out. Filters are ordered, and each one wraps the next — forming a true chain.

Spring Security registers a single servlet filter called DelegatingFilterProxy into the standard servlet container filter chain. That proxy delegates all work to a Spring-managed bean named springSecurityFilterChain. Behind that bean sits FilterChainProxy, which holds one or more SecurityFilterChain instances — each a list of Spring Security-specific filters.

Key insight: Spring Security is not magic — it is a carefully ordered list of ordinary servlet filters. Every authentication decision, every redirect to a login page, and every 403 response originates in one of those filters.

The Default Filter Order

When you add spring-boot-starter-security to a project, Spring Boot auto-configures a SecurityFilterChain with the following filters (abbreviated, in order):

  1. DisableEncodeUrlFilter — prevents session IDs from leaking into URLs.
  2. WebAsyncManagerIntegrationFilter — propagates the SecurityContext to async threads.
  3. SecurityContextHolderFilter — loads the SecurityContext from the repository (typically the HTTP session) at the start of the request and clears it afterwards.
  4. HeaderWriterFilter — adds security-related HTTP response headers (X-Frame-Options, X-Content-Type-Options, etc.).
  5. CsrfFilter — validates CSRF tokens on state-changing requests.
  6. LogoutFilter — intercepts the logout URL and clears the security context and session.
  7. UsernamePasswordAuthenticationFilter — processes form-login POST requests.
  8. DefaultLoginPageGeneratingFilter — serves the built-in login form (removed when you supply your own).
  9. BearerTokenAuthenticationFilter — extracts JWT/opaque tokens from the Authorization: Bearer header (present only when OAuth2 Resource Server is configured).
  10. RequestCacheAwareFilter — replays the original request after a successful login redirect.
  11. SecurityContextHolderAwareRequestWrapper — wraps the request to expose the Servlet security API.
  12. AnonymousAuthenticationFilter — if no authentication has been set yet, inserts an anonymous principal so downstream code never sees a null.
  13. ExceptionTranslationFilter — catches AccessDeniedException and AuthenticationException and converts them into HTTP responses (401/302 or 403).
  14. AuthorizationFilter — enforces the access rules you declared in SecurityFilterChain.
You do not need to memorise this list. What matters is the pattern: security context loading → authentication → anonymous fallback → exception translation → authorization. Each phase depends on the ones before it.

How a Request Flows Through the Chain

Consider an unauthenticated GET request to a protected resource:

  1. SecurityContextHolderFilter looks for an existing SecurityContext in the session — finds none, so it sets an empty context.
  2. Authentication filters (UsernamePassword, Bearer, etc.) look for credentials in the request — find none, so they pass through without setting a principal.
  3. AnonymousAuthenticationFilter sees that no authentication is set; it inserts an AnonymousAuthenticationToken.
  4. AuthorizationFilter evaluates the rule for the requested URL (e.g., authenticated()) against the anonymous token — access is denied.
  5. ExceptionTranslationFilter catches the AccessDeniedException. Because the current principal is anonymous, it treats this as a missing authentication and redirects the browser to the login page (or returns 401 for stateless APIs).

Now consider a POST to /login with valid credentials:

  1. UsernamePasswordAuthenticationFilter matches the URL, extracts username and password, delegates to the AuthenticationManager.
  2. The AuthenticationManager loads the user via UserDetailsService, verifies the password, and returns a populated Authentication object.
  3. The filter stores this in the SecurityContext and saves the context to the session.
  4. The configured AuthenticationSuccessHandler redirects the user to their original destination.

Multiple SecurityFilterChain Beans

Spring Security 6 allows you to define multiple SecurityFilterChain beans, each matched to a different URL pattern. This is the idiomatic way to have different security rules for, say, your REST API (stateless JWT) and your admin web UI (stateful form login).

@Configuration @EnableWebSecurity public class SecurityConfig { // Chain 1: applies only to /api/** — stateless, JWT @Bean @Order(1) public SecurityFilterChain apiChain(HttpSecurity http) throws Exception { http .securityMatcher("/api/**") .csrf(AbstractHttpConfigurer::disable) .sessionManagement(s -> s.sessionCreationPolicy(SessionCreationPolicy.STATELESS)) .authorizeHttpRequests(auth -> auth.anyRequest().authenticated()) .oauth2ResourceServer(oauth2 -> oauth2.jwt(Customizer.withDefaults())); return http.build(); } // Chain 2: applies to everything else — stateful form login @Bean @Order(2) public SecurityFilterChain webChain(HttpSecurity http) throws Exception { http .authorizeHttpRequests(auth -> auth .requestMatchers("/public/**", "/login", "/error").permitAll() .anyRequest().authenticated()) .formLogin(Customizer.withDefaults()); return http.build(); } }

FilterChainProxy evaluates each chain's securityMatcher in @Order order and uses the first matching chain. Subsequent chains are not consulted. Forgetting to set a matcher on a lower-order chain effectively makes it a catch-all and can swallow requests intended for your other chains.

Common pitfall — chain ordering: If two beans have the same @Order Spring will throw an exception at startup. Always assign an explicit and unique order to every SecurityFilterChain bean in a multi-chain setup.

Adding a Custom Filter

You can insert your own filter at a specific position in the chain using addFilterBefore, addFilterAfter, or addFilterAt. A common use case is validating a custom request header before the standard authentication filters run:

@Component public class ApiKeyFilter extends OncePerRequestFilter { @Override protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws ServletException, IOException { String apiKey = request.getHeader("X-Api-Key"); if ("expected-secret".equals(apiKey)) { // build a trusted Authentication and store it UsernamePasswordAuthenticationToken auth = new UsernamePasswordAuthenticationToken( "api-client", null, List.of(new SimpleGrantedAuthority("ROLE_API"))); SecurityContextHolder.getContext().setAuthentication(auth); } filterChain.doFilter(request, response); } } // In your SecurityFilterChain: http.addFilterBefore(apiKeyFilter, UsernamePasswordAuthenticationFilter.class);

Extending OncePerRequestFilter guarantees your filter runs exactly once per request — important in servlet containers that may dispatch internally (e.g., forward, error dispatches).

Inspecting the Chain at Runtime

During development you can log every filter that processes a request by setting the Spring Security debug flag:

# application.properties logging.level.org.springframework.security=TRACE

Or at the annotation level:

@EnableWebSecurity(debug = true)
Never enable debug = true in production. It logs full request details — headers, parameters, and security decisions — which can expose sensitive information in log aggregators.

Security Context and Thread Propagation

The SecurityContext is stored in a ThreadLocal by default. This means it is available anywhere in the call stack of the same thread — your controllers, services, and repositories — without passing it explicitly. However, if you spawn a new thread (e.g., with CompletableFuture, @Async, or a virtual thread), the context is not automatically inherited. Spring Security provides DelegatingSecurityContextExecutor and the MODE_INHERITABLETHREADLOCAL strategy to handle this, which is covered in the Method-Level Security lesson.

Summary

The Security Filter Chain is the backbone of Spring Security. Every request is intercepted by DelegatingFilterProxy, routed through FilterChainProxy, and processed by an ordered list of filters that load the security context, attempt authentication, fall back to anonymous, and then enforce authorization. Multiple chains let you apply different security strategies to different URL namespaces. Custom filters slot cleanly into this chain at any position. In the next lesson you will sharpen the distinction between authentication (who are you?) and authorization (what can you do?).

ES
Edrees Salih
1 hour ago

We are still cooking the magic in the way!