Skip to content
Spring Boot sb security 3 min read

Security Filter Chain

Spring Security is built on the servlet filter model. Every request passes through an ordered chain of filters before it reaches your controller, and each filter has a single responsibility — extracting credentials, checking authorization, handling exceptions, and so on. Understanding this chain is the key to debugging “why is my request being rejected” problems and to placing custom filters correctly.

From servlet container to Spring beans

The servlet container only knows about plain jakarta.servlet.Filter instances; it knows nothing about Spring beans. Spring bridges the two with a single registered filter, the DelegatingFilterProxy, which delegates to a Spring-managed bean named springSecurityFilterChain.

Servlet Container
  └─ DelegatingFilterProxy   (registered with the container)
       └─ FilterChainProxy    (the springSecurityFilterChain bean)
            ├─ SecurityFilterChain #1  (matches /api/**)
            │     └─ [ filter, filter, filter, ... ]
            └─ SecurityFilterChain #2  (matches everything else)
                  └─ [ filter, filter, filter, ... ]

The flow:

  1. DelegatingFilterProxy — a real servlet filter that simply forwards to the Spring bean, so Security filters get full access to the application context and dependency injection.
  2. FilterChainProxy — the central dispatcher. It holds a list of SecurityFilterChain objects and, for each request, picks the first chain whose request matcher matches.
  3. SecurityFilterChain — a matcher plus an ordered list of the actual security filters that will run for matching requests.

Note: Because FilterChainProxy selects only the first matching SecurityFilterChain, the order of your chain beans matters. Put more specific matchers (and lower @Order values) first.

Key filters and their order

A typical chain contains 15+ filters. The ones you interact with most often, roughly in execution order:

FilterResponsibility
DisableEncodeUrlFilterPrevents session id from leaking into URLs
SecurityContextHolderFilterLoads any existing SecurityContext for the request
CorsFilterHandles CORS preflight and headers (when .cors(...) is enabled)
CsrfFilterValidates CSRF tokens for state-changing requests
LogoutFilterHandles /logout
UsernamePasswordAuthenticationFilterProcesses form-login POST /login
BasicAuthenticationFilterProcesses the Authorization: Basic header
BearerTokenAuthenticationFilterProcesses Authorization: Bearer (resource server)
ExceptionTranslationFilterConverts AuthenticationException/AccessDeniedException into 401/403
AuthorizationFilterThe final gate — applies authorizeHttpRequests rules

The chain ends at the AuthorizationFilter. If it permits the request, the FilterChainProxy lets the request continue to the DispatcherServlet and ultimately your controller.

How authentication flows

Take an HTTP Basic request as an example:

GET /api/orders                Authorization: Basic dXNlcjpwYXNz


BasicAuthenticationFilter
   │  decode header → UsernamePasswordAuthenticationToken (unauthenticated)

AuthenticationManager.authenticate(token)
   │  delegates to AuthenticationProvider (e.g. DaoAuthenticationProvider)
   │  loads UserDetails, verifies password with PasswordEncoder

Authentication (authenticated, with authorities)
   │  stored in SecurityContextHolder

AuthorizationFilter checks rules → controller

SecurityContextHolder

The SecurityContextHolder is where the authenticated principal lives for the duration of the request. By default it uses a ThreadLocal, so the current Authentication is available anywhere downstream on the same thread.

SecurityContext context = SecurityContextHolder.getContext();
Authentication authentication = context.getAuthentication();

String name = authentication.getName();          // the username
Object principal = authentication.getPrincipal(); // usually a UserDetails
boolean isAdmin = authentication.getAuthorities().stream()
        .anyMatch(a -> a.getAuthority().equals("ROLE_ADMIN"));

In a controller you usually inject it instead of reaching for the holder directly:

@GetMapping("/me")
public String me(Authentication authentication) {
    return "Hello " + authentication.getName();
}

Tip: The ThreadLocal is cleared at the end of the request by SecurityContextHolderFilter. If you spawn threads (@Async), the context does not propagate automatically — use DelegatingSecurityContextExecutor or set the strategy to MODE_INHERITABLETHREADLOCAL.

Inserting a custom filter

You place a custom filter relative to a known one using addFilterBefore / addFilterAfter. This is exactly how JWT authentication is wired.

@Bean
public SecurityFilterChain filterChain(HttpSecurity http,
                                        JwtAuthFilter jwtAuthFilter) throws Exception {
    http
        .authorizeHttpRequests(auth -> auth.anyRequest().authenticated())
        .addFilterBefore(jwtAuthFilter, UsernamePasswordAuthenticationFilter.class);
    return http.build();
}

Pitfalls

  • Wrong chain matched — if a request hits an unexpected chain, check the securityMatcher/requestMatchers ordering; the first match wins.
  • Filter runs too late — adding a JWT filter after AuthorizationFilter means authorization runs before your token is parsed. Use addFilterBefore.
  • Lost context across threadsSecurityContextHolder is thread-bound; propagate it explicitly for async work.
Last updated June 13, 2026
Was this helpful?