Filter chains, SecurityContext, authentication vs authorization — how Spring Security is wired.
Published September 21, 2026
Spring Security is the standard security framework for Spring Boot applications. It handles authentication (who are you?) and authorization (what are you allowed to do?) through a chain of servlet filters.
Every HTTP request passes through a chain of filters before reaching your controller:
HTTP Request
↓
SecurityContextPersistenceFilter (load/save SecurityContext)
↓
UsernamePasswordAuthenticationFilter (form login)
↓
BearerTokenAuthenticationFilter (JWT/OAuth2)
↓
ExceptionTranslationFilter (handle 401/403)
↓
AuthorizationFilter (check permissions)
↓
Your Controller
Servlet containers (Tomcat) manage filters through their own lifecycle, entirely separate from Spring's IoC container — DelegatingFilterProxy is the bridge: a thin servlet Filter registered with the container that does nothing itself except look up a real, Spring-managed bean (FilterChainProxy, which wraps the actual security filter chain) and delegate every call to it. This is what lets Spring Security's filters be ordinary Spring beans — dependency-injected, configured via @Bean methods — while still participating correctly in the servlet container's own filter lifecycle.
@Bean
@Order(1)
SecurityFilterChain apiFilterChain(HttpSecurity http) throws Exception {
return http.securityMatcher("/api/**")
.authorizeHttpRequests(auth -> auth.anyRequest().authenticated())
.build();
}
@Bean
@Order(2)
SecurityFilterChain publicFilterChain(HttpSecurity http) throws Exception {
return http.securityMatcher("/public/**")
.authorizeHttpRequests(auth -> auth.anyRequest().permitAll())
.build();
}
A single application can register several SecurityFilterChain beans, each scoped to a different securityMatcher pattern — the first chain whose pattern matches the incoming request handles it entirely. This lets /api/** require authentication while /public/** stays open, without either configuration interfering with the other.
Filters run in a strict, fixed order (SecurityContextPersistenceFilter → CsrfFilter → UsernamePasswordAuthenticationFilter → ... → AuthorizationFilter). Each filter can either call chain.doFilter() to pass the request to the next filter (pass through), or write a response directly and never call doFilter() (short-circuit) — this is Chain of Responsibility applied directly (see Chain of Responsibility), and it's exactly why filter order matters: a filter earlier in the chain can reject a request before any later filter (including the actual authorization check) ever runs.
Sits specifically to catch AuthenticationException and AccessDeniedException thrown by later filters, translating them into the correct HTTP response — an unauthenticated request attempting a protected resource becomes a 401, an authenticated-but-unauthorized request becomes a 403. Without this filter, those exceptions would otherwise propagate as generic 500 errors.
The last filter in the standard chain, making the actual "is this request allowed" decision based on the configured authorization rules — everything before it (authentication filters, CSRF, exception translation) exists to get a correctly-populated Authentication object to this final gate.
A Filter operates at the servlet container level, before the request even reaches Spring MVC's DispatcherServlet — it has no knowledge of which controller method will eventually handle the request. A Spring HandlerInterceptor operates inside Spring MVC, after the DispatcherServlet has resolved which handler (controller method) will process the request, giving it access to the handler itself and the resulting ModelAndView. Security enforcement belongs at the Filter level specifically because it should reject unauthorized requests before any MVC-layer processing (handler resolution, argument binding) happens at all.
CORS (Cross-Origin Resource Sharing) is a browser-enforced mechanism letting a server explicitly declare which origins are allowed to call it, via Access-Control-Allow-Origin and related response headers — it protects against a malicious site's JavaScript reading responses from an API the browser has credentials for. CSRF (Cross-Site Request Forgery) is about forged requests from an already-trusted origin (a malicious site tricking a logged-in user's browser into submitting a request the user never intended) — different threat model entirely, addressed with anti-forgery tokens, not origin headers.
Authentication and authorization enforced on every endpoint (no accidentally-open routes), input validation and output encoding, rate limiting (see the Rate Limiter case study), secrets never committed to source control, and least-privilege scoping for service-to-service calls — a practical baseline worth checking explicitly on any new API surface, not just relying on framework defaults to catch every gap.
@Configuration
@EnableWebSecurity
public class SecurityConfig {
@Bean
public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
return http
.csrf(csrf -> csrf.disable()) // disable for REST APIs
.sessionManagement(sm ->
sm.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
.authorizeHttpRequests(auth -> auth
.requestMatchers("/api/v1/auth/**").permitAll() // public endpoints
.requestMatchers("/api/v1/admin/**").hasRole("ADMIN")
.anyRequest().authenticated() // everything else needs auth
)
.addFilterBefore(jwtAuthFilter, UsernamePasswordAuthenticationFilter.class)
.build();
}
@Bean
public AuthenticationManager authManager(
AuthenticationConfiguration config) throws Exception {
return config.getAuthenticationManager();
}
@Bean
public PasswordEncoder passwordEncoder() {
return new BCryptPasswordEncoder();
}
}
Spring Security loads user details through UserDetailsService:
@Service
@RequiredArgsConstructor
public class UserDetailsServiceImpl implements UserDetailsService {
private final UserRepository userRepository;
@Override
public UserDetails loadUserByUsername(String email)
throws UsernameNotFoundException {
return userRepository.findByEmail(email)
.map(user -> User.builder()
.username(user.getEmail())
.password(user.getPasswordHash())
.roles(user.getRoles().toArray(new String[0]))
.accountExpired(!user.isActive())
.build())
.orElseThrow(() ->
new UsernameNotFoundException("User not found: " + email));
}
}
// Get the current authenticated user
Authentication auth = SecurityContextHolder.getContext().getAuthentication();
String username = auth.getName();
Collection<? extends GrantedAuthority> authorities = auth.getAuthorities();
// In a controller — inject directly
@GetMapping("/me")
public UserDto getMe(@AuthenticationPrincipal UserDetails user) {
return userService.findByEmail(user.getUsername());
}
@Configuration
@EnableMethodSecurity // enables @PreAuthorize, @PostAuthorize
public class MethodSecurityConfig {}
@Service
public class AdminService {
@PreAuthorize("hasRole('ADMIN')")
public List<User> getAllUsers() { ... }
@PreAuthorize("#userId == authentication.principal.id or hasRole('ADMIN')")
public User getUser(String userId) { ... }
@PostAuthorize("returnObject.ownerId == authentication.principal.id")
public Document getDocument(String docId) { ... }
}
STATELESS session management means no HttpSession is created — every request must carry credentials (JWT).@PreAuthorize uses Spring Expression Language (SpEL) — you can access the authentication object and method arguments.The filter chain is easier to reason about once you know the objects that pass through it:
Authentication token, for example a UsernamePasswordAuthenticationToken holding a username and password.AuthenticationManager, usually a ProviderManager, which asks each configured AuthenticationProvider in turn whether it supports that token type.DaoAuthenticationProvider, loads the user via UserDetailsService, checks the password with the PasswordEncoder, checks the account's status flags (locked, disabled, expired), and returns an authenticated Authentication containing the principal and the granted authorities.SecurityContext (held by SecurityContextHolder, per thread). For session-based apps, it's also saved in the HTTP session, so later requests are recognized.AuthorizationFilter (and method security later) reads the authorities from the context, and grants or denies access.AuthenticationException or AccessDeniedException, ExceptionTranslationFilter turns it into a 401 (unauthenticated: triggering the AuthenticationEntryPoint) or a 403 (authenticated but forbidden: the AccessDeniedHandler).Supporting a new login method (an API key, a magic link, a partner's signed header) follows the same shape: a filter that builds a custom token type, and a provider that knows how to verify it. The rest of the framework keeps working unchanged.
The right defaults differ between these two cases, and mixing them is a common source of confusion:
| Concern | Browser app with sessions | Stateless API with tokens |
|---|---|---|
| Session | IF_REQUIRED: the session stores the security context | STATELESS: authenticate every request from its token |
| CSRF protection | Enabled. Cookies are sent automatically, so forged requests are possible | Usually disabled if credentials are only sent in the Authorization header, never in cookies |
| Login | Form login, or OAuth2 login with redirects | A token endpoint, or an external identity provider issuing JWTs |
| Unauthenticated response | Redirect to the login page | 401 with a JSON body, with no redirect |
If a "stateless" API actually reads its token from a cookie, CSRF protection is needed again, because the browser attaches cookies automatically.
Q: What's the difference between a 401 and a 403 in Spring Security?
A: 401 Unauthorized means the request isn't authenticated (missing or invalid credentials), and is handled by the AuthenticationEntryPoint. 403 Forbidden means the user is known but lacks permission, handled by the AccessDeniedHandler. ExceptionTranslationFilter decides which applies.
Q: What are AuthenticationManager and AuthenticationProvider?
A: The manager is the entry point that authenticates a token. The usual implementation, ProviderManager, delegates to a list of providers, each supporting certain token types (username/password, JWT, LDAP and so on). The first provider that supports the token and succeeds returns the authenticated result.
Q: When is it safe to disable CSRF protection?
A: When the application doesn't rely on credentials that browsers send automatically, such as cookies or HTTP Basic cached by the browser, for example a pure API authenticated by an Authorization: Bearer header. If any authentication travels in cookies, keep CSRF protection enabled.
Q: How does the SecurityContext reach my service code?
A: It's stored in SecurityContextHolder, which uses a ThreadLocal by default, so code running on the request thread can read the current Authentication. It doesn't automatically propagate to @Async or other threads. Use DelegatingSecurityContextExecutor, or pass the needed identity explicitly.
Q: How do you customize the JSON returned for 401 and 403 errors in an API?
A: Register a custom AuthenticationEntryPoint (for 401) and AccessDeniedHandler (for 403) in the security configuration's exceptionHandling() section, and write a consistent error body there. Controller-level @ControllerAdvice doesn't see these, because they happen in filters before the controller runs.