The security filter chain
How a request is authenticated, the SecurityContext, AuthenticationManager and providers, and the order of filters that decides everything.
Spring Security is a chain of servlet filters sitting in front of your application, and almost every "why is this 403" question is answered by knowing which filter did it and in what order.
The chain is not a metaphor. You can print it.
Sixteen filters you did not ask for
Add spring-boot-starter-security to an otherwise ordinary application, write no configuration at all, and this is what is installed:
### Spring Security installed 1 chain(s)
### chain 1 has 16 filters, in this order:
### 1. DisableEncodeUrlFilter
### 2. WebAsyncManagerIntegrationFilter
### 3. SecurityContextPersistenceFilter
### 4. HeaderWriterFilter
### 5. CsrfFilter
### 6. LogoutFilter
### 7. UsernamePasswordAuthenticationFilter
### 8. DefaultLoginPageGeneratingFilter
### 9. DefaultLogoutPageGeneratingFilter
### 10. BasicAuthenticationFilter
### 11. RequestCacheAwareFilter
### 12. SecurityContextHolderAwareRequestFilter
### 13. AnonymousAuthenticationFilter
### 14. SessionManagementFilter
### 15. ExceptionTranslationFilter
### 16. FilterSecurityInterceptorYou can print your own the same way — the chain is a bean:
@Bean CommandLineRunner printChain(@Qualifier("springSecurityFilterChain") Filter f) {
return args -> ((FilterChainProxy) f).getFilterChains()
.forEach(c -> c.getFilters().forEach(x -> System.out.println(x.getClass().getSimpleName())));
}Four positions in that list explain most of the behaviour:
- 3.
SecurityContextPersistenceFilter— loads theSecurityContext(from the session, if there is one) at the start of the request and clears it at the end. This is whySecurityContextHolder.getContext()works anywhere in your code and why it is empty in a thread you spawned yourself. - 13.
AnonymousAuthenticationFilter— if nothing authenticated the request, it installs an anonymousAuthenticationrather than leavingnull. That is why your code can callgetAuthentication().getName()without a null check, and why "is the user logged in" is nevergetAuthentication() != null. - 15.
ExceptionTranslationFilter— catches the exceptions thrown by the filter after it and turns them into responses: 401 with a challenge if nobody is authenticated, 403 if somebody is and may not. - 16.
FilterSecurityInterceptor— last, and this is where authorisation happens. Everything above it runs before any URL rule is consulted.
That ordering is the mental model worth keeping: authenticate early, authorise last, and translate the result on the way out.
The chain is built from your configuration
Write a config and filters disappear:
http.csrf().disable()
.sessionManagement().sessionCreationPolicy(STATELESS).and()
.authorizeRequests()
.antMatchers("/public/**").permitAll()
.antMatchers(HttpMethod.GET, "/admin/**").hasRole("ADMIN")
.anyRequest().authenticated().and()
.httpBasic();### chain 1 has 12 filters, in this order:
### 1. DisableEncodeUrlFilter
### 2. WebAsyncManagerIntegrationFilter
### 3. SecurityContextPersistenceFilter
### 4. HeaderWriterFilter
### 5. LogoutFilter
### 6. BasicAuthenticationFilter
### ...Four gone. CsrfFilter, because CSRF was disabled. The three form-login filters, because no form login was requested. The chain is a product of your configuration, not a fixed pipeline you configure around — which is why "add a filter" and "which order does it go in" are real questions with real answers.
401 and 403, demonstrated
The distinction the authorisation lesson insisted on is exactly what ExceptionTranslationFilter implements:
public, no credentials -> HTTP 200
private, no credentials -> HTTP 401
private, as ana -> HTTP 200
/admin, as ana (ROLE_USER) -> HTTP 403
/admin, as root (ROLE_ADMIN) -> HTTP 200401 means I do not know who you are and comes with a challenge — WWW-Authenticate: Basic realm="Realm". 403 means I know, and you may not. A client can act on that difference: 401 means sign in, 403 means stop asking.
Matcher order decides everything
authorizeRequests rules are evaluated in the order written, first match wins. This is the most common configuration bug in Spring Security and it does not produce an error:
// WRONG: anyRequest matches everything, so the admin rule is unreachable
.anyRequest().authenticated()
.antMatchers("/admin/**").hasRole("ADMIN")Put specific patterns first and anyRequest() last, always. And make anyRequest() .authenticated() or .denyAll() — never leave it out, because a URL matching no rule is a URL with no protection. Default-deny, from the security fundamentals lesson, is a property you have to configure.
Stateless, and what it actually turns off
SessionCreationPolicy.STATELESS is what an API wants, and the effect is visible:
default config: Set-Cookie: JSESSIONID=AEA288FE...; Path=/; HttpOnly
STATELESS config: (no Set-Cookie at all)No session is created and none is read, so every request must carry its own credentials. That is right for an API with token or basic authentication, and it is also what makes CSRF protection unnecessary — which is the reason csrf().disable() appears beside it rather than as an independent choice. The CORS and CSRF lesson takes that decision apart properly.
Headers you get for free
HeaderWriterFilter at position 4 adds these without being asked:
X-Content-Type-Options: nosniff
X-Frame-Options: DENY
X-XSS-Protection: 1; mode=block
Cache-Control: no-cache, no-store, max-age=0, must-revalidate
Pragma: no-cache
Expires: 0nosniff stops a browser guessing a content type it was not given. X-Frame-Options: DENY prevents your pages being framed, which is clickjacking prevention. The cache headers stop an authenticated response being stored.
Worth knowing because the usual way people meet these is by turning them off to fix something — an embed that needs framing, a response that should be cached. Change the one you need and leave the rest.
AuthenticationManager, providers and UserDetailsService
The authentication filters do not check credentials themselves. They build an Authentication object and hand it to the AuthenticationManager, which asks each configured AuthenticationProvider whether it can handle it.
For username and password that provider is DaoAuthenticationProvider, which uses two beans you supply:
@Bean PasswordEncoder encoder() { return new BCryptPasswordEncoder(); }
@Bean UserDetailsService users(PasswordEncoder enc) {
return new InMemoryUserDetailsManager(
User.withUsername("ana").password(enc.encode("secret")).roles("USER").build(),
User.withUsername("root").password(enc.encode("secret")).roles("USER","ADMIN").build());
}In a real system UserDetailsService loads from your database, and the password it returns is the stored bcrypt hash from the password lesson — the provider calls matches(), never equals().
Note roles("USER") produces the authority ROLE_USER. That prefix is the whole difference between hasRole("USER") and hasAuthority("ROLE_USER"), and it is why hasRole("ROLE_USER") silently never matches.