When you define multiple SecurityFilterChain beans, how does FilterChainProxy decide which chain handles a request, and how does that interact with filter ordering?
answer
- Two orderings: between chains (@Order, first-match) vs within chain (fixed)
- FilterChainProxy: first matching chain wins, others skipped, not additive
- Catch-all (no securityMatcher) must be declared LAST
- securityMatcher picks the chain; requestMatchers picks authz rules inside
- No match = unsecured passthrough
basics
~20 sFilterChainProxy tries each SecurityFilterChain in bean order and uses the FIRST one whose request matcher matches — only that chain's filters run. So a broad or unordered chain declared first can shadow a more specific one. Order the beans with @Order and give each a securityMatcher.
solid answer
~40 s`FilterChainProxy` holds a *list* of `SecurityFilterChain`s and, per request, picks the **first** whose `matches(request)` returns true; only that single chain's internal filters execute (the others are skipped entirely). Chain selection order is the bean order — controlled with `@Order`/`@Bean` ordering — so a chain with no `securityMatcher` (which matches everything) must be declared **last**, or it will shadow later, more specific chains. Within the chosen chain, the internal filter ordering (SecurityContextHolderFilter -> CsrfFilter -> ... -> AuthorizationFilter) is independent and still governed by `FilterOrderRegistration`. So there are two orderings to reason about: *between* chains (bean/`@Order`, first-match-wins) and *within* a chain (fixed filter order). A classic bug is putting a catch-all API chain before a specific `/actuator/**` chain — the API chain wins and the actuator rules never apply.
code
java · 27 lines@Configuration
@EnableWebSecurity
public class MultiChainConfig {
@Bean
@Order(1) // most specific, tried first
SecurityFilterChain apiChain(HttpSecurity http) throws Exception {
http
.securityMatcher("/api/**") // this chain claims /api/**
.csrf(csrf -> csrf.disable()) // stateless: no CsrfFilter
.sessionManagement(s -> s.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
.oauth2ResourceServer(o -> o.jwt(Customizer.withDefaults())) // BearerTokenAuthenticationFilter
.authorizeHttpRequests(a -> a.anyRequest().authenticated());
return http.build();
}
@Bean
@Order(2) // fallback / web, tried last — NO securityMatcher => matches everything
SecurityFilterChain webChain(HttpSecurity http) throws Exception {
http
.authorizeHttpRequests(a -> a
.requestMatchers("/login", "/css/**").permitAll()
.anyRequest().authenticated())
.formLogin(Customizer.withDefaults()); // UsernamePasswordAuthenticationFilter + CsrfFilter
return http.build();
}
}go deeper
Usually one chain; know that a bean defines the chain.
Know multiple chains exist and each can have a securityMatcher.
Explain first-match-wins, @Order precedence, and the catch-all-last rule.
Separate the two orderings cleanly, reason about shadowing, non-additive chains, unsecured passthrough on no match, and securityMatcher vs requestMatchers.
## Two independent orderings There are two distinct 'orders' in Spring Security, and conflating them is a common senior/principal mistake: 1. **Between `SecurityFilterChain` beans** — which chain handles a given request. 2. **Within a chain** — the fixed sequence of internal filters (SecurityContextHolderFilter, CsrfFilter, UsernamePasswordAuthenticationFilter, ExceptionTranslationFilter, AuthorizationFilter, ...). ### Chain selection: first match wins `FilterChainProxy` stores an ordered `List<SecurityFilterChain>`. For each request it iterates in order and calls `chain.matches(request)`; the **first** match is chosen and its `getFilters()` list runs. Crucially, **no other chain runs** — chains are not additive. If none match, no security filters run (the request proceeds unsecured through the proxy). ### Where the between-chain order comes from The list order equals the bean order. You control it with **`@Order`** on the `@Bean` methods (lower value = earlier = higher precedence), or by declaration order if using `@Order`/`SecurityFilterChain` with explicit priorities. Each chain typically declares a `securityMatcher(...)` on `HttpSecurity` to scope which requests it claims; a chain **without** a `securityMatcher` matches *all* requests. ### The shadowing gotcha Because selection is first-match-wins: - A **catch-all chain** (no `securityMatcher`, or `anyRequest`) placed **before** a specific one will consume every request, and the specific chain never runs. Fix: give the catch-all the **highest `@Order` value** (lowest precedence) so it's tried last, and/or give every specific chain a narrow `securityMatcher`. - Two chains with overlapping matchers: the earlier bean wins for the overlap. ### Interaction with internal filter ordering Once a chain is chosen, the *within-chain* filter order is unaffected by which chain won — each chain builds its own filter list via the DSL, and `FilterOrderRegistration` orders that list. So you might have: - Chain A (`/api/**`): stateless, `csrf().disable()` (no `CsrfFilter`), a `BearerTokenAuthenticationFilter`, then `ExceptionTranslationFilter`, `AuthorizationFilter`. - Chain B (everything else): `CsrfFilter`, `UsernamePasswordAuthenticationFilter` (form login), etc. Each chain has its own correctly-ordered internal pipeline; they don't share filters. ### Practical rules - Give **every** chain an explicit `securityMatcher` except possibly a single fallback declared **last**. - Use `@Order(1)`, `@Order(2)`, ... to make between-chain precedence explicit and reviewable. - Remember chains are **not** combined: a request matched by the API chain will not also get the web chain's CSRF protection — that's by design and why disabling CSRF on the stateless chain is safe without weakening the web chain. - Debugging: enable `logging.level.org.springframework.security=DEBUG`; Spring logs which chain matched and the ordered filter list at startup. ### Edge cases - **No matching chain** = unsecured passthrough. Always have a fallback chain if you want a deny-by-default posture. - **`securityMatcher` vs `requestMatchers`**: `securityMatcher` (on `HttpSecurity`) decides *which chain*; `requestMatchers` inside `authorizeHttpRequests` decides *authorization rules within* the chosen chain. Mixing them up produces surprising results — e.g. authorization rules for a path that the chain never even claims. - **`@Order` ties / missing `@Order`**: rely on explicit ordering; ambiguous ordering makes shadowing non-deterministic across refactors.
- If a request matches two chains, do both run?No. FilterChainProxy picks only the first matching chain in bean order and runs solely its filters; the other chain is skipped entirely. Chains are not additive.
- A team declares a chain with no securityMatcher first, then a specific /admin/** chain. The admin rules seem ignored. Why?The first chain has no securityMatcher, so it matches every request and wins (first-match). The /admin/** chain is never reached. Fix: declare the catch-all last (highest @Order value) or give it a narrow securityMatcher.
- What happens if no SecurityFilterChain matches a request?No security filters run for that request — it passes through FilterChainProxy unsecured. For deny-by-default, ensure a fallback chain matches everything.
saying these in an interview costs you the question
- Thinking multiple matching chains combine (all their filters run).
- Declaring a catch-all chain (no securityMatcher) before specific chains.
- Confusing securityMatcher (chain selection) with requestMatchers (authorization within a chain).
- Assuming an unmatched request is denied rather than passed through unsecured.