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:
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.FilterChainProxy— the central dispatcher. It holds a list ofSecurityFilterChainobjects and, for each request, picks the first chain whose request matcher matches.SecurityFilterChain— a matcher plus an ordered list of the actual security filters that will run for matching requests.
Note: Because
FilterChainProxyselects only the first matchingSecurityFilterChain, the order of your chain beans matters. Put more specific matchers (and lower@Ordervalues) first.
Key filters and their order
A typical chain contains 15+ filters. The ones you interact with most often, roughly in execution order:
| Filter | Responsibility |
|---|---|
DisableEncodeUrlFilter | Prevents session id from leaking into URLs |
SecurityContextHolderFilter | Loads any existing SecurityContext for the request |
CorsFilter | Handles CORS preflight and headers (when .cors(...) is enabled) |
CsrfFilter | Validates CSRF tokens for state-changing requests |
LogoutFilter | Handles /logout |
UsernamePasswordAuthenticationFilter | Processes form-login POST /login |
BasicAuthenticationFilter | Processes the Authorization: Basic header |
BearerTokenAuthenticationFilter | Processes Authorization: Bearer (resource server) |
ExceptionTranslationFilter | Converts AuthenticationException/AccessDeniedException into 401/403 |
AuthorizationFilter | The 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
ThreadLocalis cleared at the end of the request bySecurityContextHolderFilter. If you spawn threads (@Async), the context does not propagate automatically — useDelegatingSecurityContextExecutoror set the strategy toMODE_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/requestMatchersordering; the first match wins. - Filter runs too late — adding a JWT filter after
AuthorizationFiltermeans authorization runs before your token is parsed. UseaddFilterBefore. - Lost context across threads —
SecurityContextHolderis thread-bound; propagate it explicitly for async work.