Spring Security Internals: Authentication Flow, Custom Filters, Multiple Chains
To debug 401 and 403 errors and write custom security you must know what happens inside the filter chain. This page follows one request from the network to the controller, then shows a custom filter, several filter chains, CORS, and JSON error responses.
Authentication flow step by step (HTTP Basic example)
1. Request: GET /api/orders Authorization: Basic cmFtOnJhbTEyMw==
2. FilterChainProxy picks the first SecurityFilterChain whose securityMatcher matches the URL
3. BasicAuthenticationFilter reads the header
└─ creates UsernamePasswordAuthenticationToken(username, password) [authenticated = false]
4. ProviderManager (AuthenticationManager) asks each AuthenticationProvider: "do you support this token?"
5. DaoAuthenticationProvider
├─ UserDetailsService.loadUserByUsername("ram") ─► UserDetails (hash, roles)
├─ PasswordEncoder.matches(rawPassword, hash) ─► false ─► BadCredentialsException ─► 401
└─ returns a NEW token with authorities [authenticated = true]
6. The filter stores it: SecurityContextHolder.getContext().setAuthentication(token) (ThreadLocal)
7. AuthorizationFilter: AuthorizationManager checks rules (hasRole ...) ─► no access ─► 403
8. DispatcherServlet ─► controller. In the controller: @AuthenticationPrincipal / Authentication / Principal
9. After the request: the SecurityContext is cleared from the thread
(with sessions it is saved in the HttpSession by SecurityContextRepository, so step 3 is skipped next time)| Status | Meaning | Thrown by |
|---|---|---|
| 401 Unauthorized | Not authenticated or bad credentials (really means unauthenticated) | AuthenticationEntryPoint after AuthenticationException |
| 403 Forbidden | Authenticated, but not allowed. Also CSRF token failure on POST | AccessDeniedHandler after AccessDeniedException |
Custom filter: API key authentication
public class ApiKeyAuthFilter extends OncePerRequestFilter {
private final Map<String, String> keyToClient; // key -> client name
public ApiKeyAuthFilter(Map<String, String> keyToClient) { this.keyToClient = keyToClient; }
@Override
protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response,
FilterChain chain) throws ServletException, IOException {
String key = request.getHeader("X-API-Key");
String client = key == null ? null : keyToClient.get(key);
if (client != null) {
Authentication auth = new UsernamePasswordAuthenticationToken(
client, null, List.of(new SimpleGrantedAuthority("ROLE_SERVICE"))); // 3 args = authenticated
SecurityContextHolder.getContext().setAuthentication(auth);
}
chain.doFilter(request, response); // always continue. AuthorizationFilter decides later
}
}Multiple security filter chains
@Configuration
@EnableWebSecurity
public class MultiChainConfig {
@Bean @Order(1) // checked first
SecurityFilterChain api(HttpSecurity http) throws Exception {
http.securityMatcher("/api/**")
.csrf(csrf -> csrf.disable())
.sessionManagement(s -> s.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
.addFilterBefore(new ApiKeyAuthFilter(Map.of("secret-key-1", "billing-service")),
UsernamePasswordAuthenticationFilter.class)
.authorizeHttpRequests(a -> a.anyRequest().hasRole("SERVICE"))
.exceptionHandling(e -> e
.authenticationEntryPoint((req, res, ex) -> writeJson(res, 401, "Unauthorized"))
.accessDeniedHandler((req, res, ex) -> writeJson(res, 403, "Forbidden")));
return http.build();
}
@Bean @Order(2) // everything else: a normal website with login form
SecurityFilterChain web(HttpSecurity http) throws Exception {
http.authorizeHttpRequests(a -> a.requestMatchers("/", "/login", "/css/**").permitAll().anyRequest().authenticated())
.formLogin(Customizer.withDefaults())
.logout(Customizer.withDefaults());
return http.build();
}
private static void writeJson(HttpServletResponse res, int status, String message) throws IOException {
res.setStatus(status);
res.setContentType("application/json");
res.getWriter().write("{\"status\":" + status + ",\"message\":\"" + message + "\"}");
}
}CORS for a browser front end
@Bean
CorsConfigurationSource corsConfigurationSource() {
CorsConfiguration cfg = new CorsConfiguration();
cfg.setAllowedOrigins(List.of("https://app.example.com")); // never "*" together with credentials
cfg.setAllowedMethods(List.of("GET", "POST", "PUT", "DELETE", "OPTIONS"));
cfg.setAllowedHeaders(List.of("Authorization", "Content-Type"));
cfg.setAllowCredentials(true);
cfg.setMaxAge(3600L);
UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource();
source.registerCorsConfiguration("/api/**", cfg);
return source;
}
// in the filter chain: http.cors(Customizer.withDefaults()); (uses the bean above)Browsers send an OPTIONS preflight request before a cross-origin call with custom headers. Security must let it pass, and the CORS filter runs before authentication. If preflight gets 401, CORS is not configured in the security chain.
Read the current user
@GetMapping("/me")
public Map<String, Object> me(Authentication auth, @AuthenticationPrincipal UserDetails user) {
return Map.of("name", auth.getName(), "roles", auth.getAuthorities().toString(), "username", user.getUsername());
}
// anywhere: SecurityContextHolder.getContext().getAuthentication()
// In @Async or new threads the context is NOT copied. Use DelegatingSecurityContextExecutor or a context propagating executor.Debug
logging.level.org.springframework.security=DEBUG
# logs each request: which chain matched, which filters ran, why access was deniedCommon mistake: Adding a custom filter as a @Component. Spring Boot then registers it as a servlet filter AND you add it in the security chain, so it runs twice. Create it with new in the chain, or disable the extra registration with a FilterRegistrationBean.
Interview questions
Why do I get 403 on POST but GET works?
CSRF protection is on and the POST has no CSRF token. Send the token for browser forms, or disable CSRF for a stateless token API.
What is the difference between 401 and 403?
401 means the caller is not authenticated. 403 means the caller is known but not allowed.
How does Spring Security know the current user in a controller?
The authenticated token is stored in the SecurityContext, a ThreadLocal, filled by an authentication filter and cleared after the request.