skip to content

When you define multiple SecurityFilterChain beans, how does FilterChainProxy decide which chain handles a request, and how does that interact with filter ordering?

level: principalimportance: should knowfreq 35%

answer

  1. Two orderings: between chains (@Order, first-match) vs within chain (fixed)
  2. FilterChainProxy: first matching chain wins, others skipped, not additive
  3. Catch-all (no securityMatcher) must be declared LAST
  4. securityMatcher picks the chain; requestMatchers picks authz rules inside
  5. No match = unsecured passthrough

basics

~20 s

FilterChainProxy 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
java
@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

for a junior

Usually one chain; know that a bean defines the chain.

for a middle

Know multiple chains exist and each can have a securityMatcher.

for a senior

Explain first-match-wins, @Order precedence, and the catch-all-last rule.

for a principal

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.

context