Home / Spring Boot / Security / Spring Security Internals: Authentication Flow, Custom Filters, Multiple Chains

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)
StatusMeaningThrown by
401 UnauthorizedNot authenticated or bad credentials (really means unauthenticated)AuthenticationEntryPoint after AuthenticationException
403 ForbiddenAuthenticated, but not allowed. Also CSRF token failure on POSTAccessDeniedHandler 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 denied

Common 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.