skip to content

How do you design authorization across multiple SecurityFilterChains, and what default-deny practices apply to authorizeHttpRequests?

level: principalimportance: should knowfreq 35%

answer

  1. securityMatcher = which chain; requestMatchers = access within
  2. @Order picks first matching chain, only one runs
  3. each chain needs its own anyRequest()
  4. default-deny: allowlist + anyRequest().denyAll()/authenticated()
  5. pair URL rules with @PreAuthorize + test 401/403

basics

~10 s

Split concerns into multiple SecurityFilterChain beans, each scoped with securityMatcher, ordered with @Order. Within each chain, enforce default-deny by ending authorizeHttpRequests with anyRequest().denyAll() or authenticated().

solid answer

~50 s

For non-trivial apps I define multiple SecurityFilterChain beans, each scoped to a slice of requests via http.securityMatcher(...) and prioritized with @Order — for example one chain for /api/** using stateless JWT/resource-server config and another for the browser UI with form login and CSRF. Only the first chain whose securityMatcher matches a request runs; its authorizeHttpRequests rules then decide access, and other chains are not consulted. Within each chain I apply default-deny: list explicit permitAll endpoints (health, login, public assets) first, then role/scope rules, and finish with anyRequest().authenticated() or denyAll() so nothing is implicitly open. I keep matchers aligned with MVC routing (MvcRequestMatcher) to avoid variant bypasses, keep chains from overlapping ambiguously, and complement URL rules with method security (@PreAuthorize) for defense in depth. I also treat rule order as a reviewable invariant and add tests asserting protected paths return 401/403.

code

java · 29 lines
java
@Configuration
@EnableWebSecurity
@EnableMethodSecurity   // enables @PreAuthorize for defense-in-depth
class SecurityConfig {

    @Bean @Order(1)
    SecurityFilterChain api(HttpSecurity http) throws Exception {
        http.securityMatcher("/api/**")                 // this chain owns /api/**
            .authorizeHttpRequests(a -> a
                .requestMatchers("/api/public/**").permitAll()
                .requestMatchers(HttpMethod.GET, "/api/**").authenticated()
                .anyRequest().denyAll())                // default-deny within the chain
            .csrf(c -> c.disable())
            .sessionManagement(s -> s.sessionCreationPolicy(
                SessionCreationPolicy.STATELESS))
            .oauth2ResourceServer(o -> o.jwt(Customizer.withDefaults()));
        return http.build();
    }

    @Bean @Order(2)
    SecurityFilterChain web(HttpSecurity http) throws Exception {
        http.authorizeHttpRequests(a -> a               // catch-all chain
                .requestMatchers("/", "/login", "/assets/**").permitAll()
                .requestMatchers("/admin/**").hasRole("ADMIN")
                .anyRequest().authenticated())
            .formLogin(Customizer.withDefaults());
        return http.build();
    }
}

go deeper

for a junior

Not expected to design multi-chain setups; knows a single chain and anyRequest.

for a middle

Knows anyRequest default-deny and that @PreAuthorize exists.

for a senior

Can build a two-chain API+web setup and explain first-chain-wins selection.

for a principal

Owns the authz architecture: chain ordering, per-chain default-deny, matcher hygiene, method-security defense-in-depth, and invariant tests.

## Two levels of matching There are **two distinct matcher stages**: 1. **`http.securityMatcher(...)`** — selects which requests a *whole* `SecurityFilterChain` handles. Spring picks the **first** chain (by `@Order`) whose `securityMatcher` matches; that chain alone processes the request. 2. **`authorizeHttpRequests` → `requestMatchers(...)`** — inside the chosen chain, per-rule authorization (first-match-wins, covered elsewhere). Confusing these is a common architecture mistake: `securityMatcher` decides *which chain*, `requestMatchers` decides *access within that chain*. ## Multiple SecurityFilterChain beans Define several beans and order them: ```java @Bean @Order(1) SecurityFilterChain apiChain(HttpSecurity http) throws Exception { http.securityMatcher("/api/**") .authorizeHttpRequests(a -> a .requestMatchers("/api/public/**").permitAll() .anyRequest().authenticated()) .csrf(csrf -> csrf.disable()) // stateless API .sessionManagement(s -> s.sessionCreationPolicy(STATELESS)) .oauth2ResourceServer(o -> o.jwt(withDefaults())); return http.build(); } @Bean @Order(2) SecurityFilterChain webChain(HttpSecurity http) throws Exception { http // no securityMatcher => matches everything else (catch-all chain) .authorizeHttpRequests(a -> a .requestMatchers("/", "/login", "/assets/**").permitAll() .anyRequest().authenticated()) .formLogin(withDefaults()); return http.build(); } ``` Key points: - Lower `@Order` = higher priority; the API chain is checked first, the web chain acts as the fallback (no `securityMatcher` = matches all). - Only **one** chain runs per request. The web chain's rules never apply to `/api/**` and vice versa. So each chain needs its **own** complete authorization policy including its own `anyRequest()`. - Different chains can have different authentication (JWT vs form), CSRF, and session policies — that's the main reason to split. ## Default-deny discipline **Fail closed, not open.** Practices: - End every chain's `authorizeHttpRequests` with `anyRequest().authenticated()` or `anyRequest().denyAll()`. Never rely on the absence of a rule. - Prefer an allowlist: explicitly `permitAll()` the small set of public endpoints; everything else requires auth. - Remember the historical hazard: if no rule matches and there is no `anyRequest()`, the delegating manager grants access. `anyRequest()` removes that ambiguity. - Use `denyAll()` for endpoints that must never be reachable through a given chain (e.g. internal actuator endpoints not exposed). ## Defense in depth URL-based rules are coarse. Combine with **method security** (`@EnableMethodSecurity` + `@PreAuthorize`, `@PostAuthorize`) so authorization is enforced at the service/controller method too — protecting against routing mismatches and internal callers. Both are backed by the same `AuthorizationManager` abstraction in Spring Security 6. ## Matcher hygiene at scale - Keep chain `securityMatcher`s non-overlapping or intentionally ordered; overlapping matchers with wrong `@Order` route requests to the wrong policy. - Use MVC-aligned matchers (`MvcRequestMatcher`) to prevent trailing-slash/variant bypass. - Beware the DispatcherServlet-path ambiguity error and disambiguate with servlet paths. ## Testing as an invariant Rule ordering and default-deny are security invariants — assert them. Use `@WebMvcTest` / `MockMvc` with `spring-security-test` to check that protected URLs return 401/403 for anonymous/under-privileged users and 200 for authorized ones. This catches shadowing bugs and accidental `permitAll()`. ## When to use one vs many chains - **One chain** — a single, uniform auth model (e.g. all form login). - **Multiple chains** — mixed auth models (API tokens + browser sessions), different CSRF/session needs, or clean separation of concerns. Don't over-split; each chain is a full policy to maintain.

  • When multiple SecurityFilterChain beans exist, how many run for a given request, and how is the winner chosen?
    Exactly one runs. Spring evaluates chains in @Order sequence and selects the first whose securityMatcher matches the request; a chain with no securityMatcher matches everything and should be ordered last as the fallback. The other chains' authorizeHttpRequests rules are never consulted for that request.
  • Why combine authorizeHttpRequests with @PreAuthorize instead of relying on URL rules alone?
    Defense in depth. URL rules are coarse and vulnerable to routing/matcher mismatches; method security enforces authorization at the invocation regardless of how the method was reached (including internal callers), so a gap in one layer doesn't expose the operation.
  • What is the safest way to end an authorizeHttpRequests block and why?
    anyRequest().authenticated() or anyRequest().denyAll() — a default-deny catch-all. Without it, a request matching no rule can be granted access by the delegating AuthorizationManager, leaving endpoints unintentionally open.

saying these in an interview costs you the question

  • Assuming all SecurityFilterChains run and combine for one request
  • Putting a single anyRequest() and expecting it to cover other chains
  • Confusing securityMatcher (chain scope) with requestMatchers (rule scope)
  • Relying on absence of a rule instead of explicit default-deny

context