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.