beginner~2h

Spring Security Architecture: The Filter Chain & Internal Flow

Spring Security is implemented entirely as a chain of servlet filters. This chapter is that chain — what gets added the moment you include the starter, and the exact order requests flow through it.

Learning objectives

  • Beginner: List the defaults spring-boot-starter-security turns on with zero configuration.
  • Intermediate: Write a minimal custom SecurityFilterChain bean for Spring Security 6.x, without WebSecurityConfigurerAdapter.
  • Advanced: Configure environment-specific security rules with @Profile and reason about a full form-login-to-logout flow.

🌱 BEGINNER STORY

Continuing our airport analogy from Chapter 1: Spring Security's filter chain is like a specific, numbered sequence of checkpoints. First a document-format checker (does your boarding pass even look valid — CorsFilter/CsrfFilter), then someone checking your ticket against your ID (UsernamePasswordAuthenticationFilter or BasicAuthenticationFilter), then a supervisor who decides what happens if you failed the check (ExceptionTranslationFilter), and finally a gate agent who checks whether your specific ticket allows boarding THIS flight (AuthorizationFilter). Only if you pass every checkpoint do you reach the plane (your @RestController).

The moment you add the spring-boot-starter-security dependency to a Spring Boot project — even with zero configuration — Spring Boot auto-configures a complete, working (if very basic) security setup:

  • Every endpoint requires authentication by default.

  • A default in-memory user named 'user' is created, with a randomly generated password printed to the console log at startup.

  • Both formLogin (an HTML login page) and httpBasic (Base64 credentials in the Authorization header) are enabled.

  • CSRF protection is enabled by default.

This is intentionally a 'secure by default, insecure only if you explicitly loosen it' philosophy — a deliberate design decision so that developers don't accidentally ship an unprotected application.

Let's deep-dive into exactly what happens, filter by filter, for a typical username/password login request:

    1. Request arrives at the servlet container (Tomcat) and hits DelegatingFilterProxy.
    1. DelegatingFilterProxy forwards to FilterChainProxy, the Spring-managed entry point.
    1. FilterChainProxy picks the matching SecurityFilterChain (you can have several, matched by URL pattern) and starts executing its filters in order.
    1. Early filters handle cross-cutting concerns: DisableEncodeUrlFilter, CorsFilter (handles preflight/cross-origin), CsrfFilter (validates CSRF token on state-changing requests).
    1. UsernamePasswordAuthenticationFilter (for form login) intercepts POST /login, extracts username & password, and builds an unauthenticated UsernamePasswordAuthenticationToken.
    1. This token is handed to the AuthenticationManager (in practice, ProviderManager), which loops through configured AuthenticationProvider beans until one can handle the token type.
    1. DaoAuthenticationProvider calls UserDetailsService.loadUserByUsername(), then PasswordEncoder.matches() to verify the password.
    1. On success, a fully-populated Authentication object (with granted authorities) is stored in the SecurityContext, which lives in the SecurityContextHolder for the duration of the request (and, in a stateful app, is persisted into the HttpSession for future requests).
    1. ExceptionTranslationFilter wraps everything downstream — if an AuthenticationException or AccessDeniedException bubbles up, it converts that into the correct HTTP response (401 via AuthenticationEntryPoint, or 403 via AccessDeniedHandler).
    1. Finally, AuthorizationFilter (previously FilterSecurityInterceptor pre-Spring-Security-6) checks the authenticated user's authorities against the configured access rules for the requested URL, and either lets the request through to the controller or throws AccessDeniedException.

Figure: A simplified view of the ordered Spring Security filter chain — order matters, and each filter has exactly one responsibility.

Figure: The internal authentication flow: Filter -> AuthenticationManager -> AuthenticationProvider -> UserDetailsService + PasswordEncoder -> SecurityContextHolder.

Interface/ClassResponsibility
SecurityFilterChainA @Bean that defines an ordered set of security filters for a matching URL pattern (replaces the old WebSecurityConfigurerAdapter approach).
AuthenticationManagerThe top-level interface responsible for processing an Authentication request; in practice implemented by ProviderManager.
ProviderManagerThe standard AuthenticationManager implementation that delegates to a list of AuthenticationProvider beans.
AuthenticationProviderPerforms the actual authentication logic for one mechanism (e.g., DaoAuthenticationProvider for username/password).
AuthenticationRepresents the principal + credentials + granted authorities, both before (unauthenticated) and after (authenticated) verification.
SecurityContextHolds the current thread's Authentication object.
SecurityContextHolderA ThreadLocal-based (by default) holder that exposes the SecurityContext for the current request thread.

@Configuration

@EnableWebSecurity public class ProjectSecurityConfig { @Bean SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception { http.authorizeHttpRequests(requests -> requests .requestMatchers("/api/public/", "/register").permitAll() .requestMatchers("/api/accounts/", "/api/loans/**").authenticated() .anyRequest().denyAll()) .formLogin(Customizer.withDefaults()) .httpBasic(Customizer.withDefaults()); return http.build(); } @Bean PasswordEncoder passwordEncoder() { return new BCryptPasswordEncoder(); } }

Notice there is no WebSecurityConfigurerAdapter anywhere — since Spring Security 5.7 (fully removed in 6.x), configuration is done purely by exposing @Bean definitions and using the lambda DSL (requests -> requests...) rather than method chaining directly on HttpSecurity subclasses.

A common beginner question: what exactly happens if a client calls a protected endpoint with NO Authorization header at all? Walking through it: UsernamePasswordAuthenticationFilter and BasicAuthenticationFilter both see nothing to process and pass the request along unauthenticated. AuthorizationFilter then evaluates the access rule for that URL, sees the principal is anonymous (Spring Security actually assigns an AnonymousAuthenticationToken by default, rather than a null Authentication — this avoids constant null-checks throughout the framework), determines the rule requires authentication, and throws an AccessDeniedException, which ExceptionTranslationFilter converts into a 401 response via the configured AuthenticationEntryPoint (by default, a page redirect for formLogin, or a WWW-Authenticate header challenge for httpBasic).

🌱 BEGINNER STORY

So far we've talked about the filter chain in the abstract. Now let's build the thing most people picture when they hear 'login': a real HTML login page, a logout button, and a page that shows 'Welcome, John' after login — the classic server-rendered (MVC) flow, as opposed to a pure REST API. Think of it like a hotel: you don't just get a key card issued invisibly — there's a physical front desk (the login PAGE), a moment where the desk clerk hands you the card (the login form POST), and a checkout counter where you hand the card back (logout).

Figure: The full MVC form-login lifecycle: redirect to login, authenticate, establish a session, browse, then logout invalidates everything.

formLogin() with zero arguments gives you Spring Security's built-in, generic login page — perfectly fine for a demo, but every real application wants its OWN branded login page. This requires three things wired together: a controller that serves your custom HTML, telling Spring Security the URL of that page, and a Thymeleaf template that actually submits the credentials correctly (including the CSRF token, which formLogin() still protects by default).

@Bean

SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception { http.authorizeHttpRequests(requests -> requests .requestMatchers("/", "/home", "/login", "/register").permitAll() .anyRequest().authenticated()) .formLogin(form -> form .loginPage("/login") // your custom page, not the generic default .defaultSuccessUrl("/dashboard", true) .failureUrl("/login?error=true") .permitAll()) .logout(logout -> logout .logoutUrl("/logout") .logoutSuccessUrl("/login?logout=true") .invalidateHttpSession(true) .deleteCookies("JSESSIONID")); return http.build(); } @Controller public class LoginController { @GetMapping("/login") public String loginPage() { return "login"; // resolves to templates/login.html } }

Notice loginPage("/login") is deliberately included in permitAll() — a subtle but important detail: if the login page itself required authentication, you'd have an impossible chicken-and-egg situation where an unauthenticated user gets redirected to a login page they're not allowed to see, creating a redirect loop.

Logout, precisely: logout() doesn't just 'forget' the user — invalidateHttpSession(true) destroys the server-side HttpSession entirely, deleteCookies("JSESSIONID") tells the browser to drop the session cookie, and Spring Security also clears the SecurityContextHolder for the current thread. All three matter: skipping the cookie deletion, for instance, can leave a stale (though now-invalid) cookie sitting in the browser.

🌱 BEGINNER STORY

A bank's HEAD OFFICE might allow employees to badge in with just an employee ID during internal testing/training days, but the REAL branch enforces full ID + fingerprint + manager approval. Same building, same badge system — different STRICTNESS depending on which 'environment' you're in. @Profile lets you do exactly this: ship one security-config class for local/dev (looser, so you can test quickly) and a stricter one for production, without a single if-statement scattered through your code.

@Configuration

@Profile("dev") public class DevSecurityConfig { @Bean SecurityFilterChain devFilterChain(HttpSecurity http) throws Exception { http.authorizeHttpRequests(r -> r.anyRequest().permitAll()) // wide open for local testing .csrf(AbstractHttpConfigurer::disable); return http.build(); } } @Configuration @Profile("prod") public class ProdSecurityConfig { @Bean SecurityFilterChain prodFilterChain(HttpSecurity http) throws Exception { http.authorizeHttpRequests(r -> r .requestMatchers("/api/public/**").permitAll() .anyRequest().authenticated()) .requiresChannel(channel -> channel.anyRequest().requiresSecure()) // force HTTPS .csrf(Customizer.withDefaults()); return http.build(); } } # application.properties (or an env var / --spring.profiles.active flag at deploy time) spring.profiles.active=prod

⚠ A real incident this pattern prevents

A very common real-world mistake is testing with a wide-open, CSRF-disabled config locally, then FORGETTING that config was ever loosened, and accidentally deploying the same relaxed settings to production because the 'dev' and 'prod' logic lived in the same class guarded by a runtime if-check that someone mis-set. Using @Profile with entirely SEPARATE @Configuration classes makes the two configurations physically impossible to accidentally cross-wire — the wrong bean simply won't be created unless that exact profile is active.

⚠ Traps developers fall into

Ordering multiple SecurityFilterChain beans incorrectly: Spring evaluates them in @Order sequence and stops at the first chain whose securityMatcher matches the request — a broad chain declared first can silently swallow requests meant for a more specific chain declared later. Forgetting that anyRequest().denyAll() (or authenticated()) as a catch-all is a best practice — omitting it can leave newly added endpoints unintentionally public. Assuming formLogin() and httpBasic() are mutually exclusive — they can both be enabled simultaneously, and Spring Security will pick whichever mechanism matches the incoming request (a browser gets the login page, a REST client sending Basic auth headers gets validated directly). Believing SecurityContextHolder is automatically thread-safe across async boundaries — by default it uses a ThreadLocal strategy, so a new thread (e.g., via @Async or a manually spawned thread) will NOT see the authenticated context unless you switch the holder strategy to MODE_INHERITABLETHREADLOCAL or explicitly propagate the context.

Want a visual for this concept?

Generate a diagram tailored to “Spring Security Architecture: The Filter Chain & Internal Flow” — the AI picks whichever visual (flowchart, comparison, sequence, etc.) best fits.

Sign in to generate a visual →

Practice quiz

Next Step

Continue to Managing Users: UserDetailsService & UserDetailsManager← Back to all Spring Security chapters