skip to content

You need a stateless JWT API and a session-based server-rendered UI in the same app. How do you structure the SecurityFilterChains, and what ordering pitfalls apply?

level: principalimportance: should knowfreq 30%

answer

  1. two beans: api securityMatcher first, catch-all last
  2. STATELESS + csrf off + jwt for api
  3. session + formLogin + csrf for ui
  4. first-match: catch-all shadows if ordered early
  5. different AuthenticationEntryPoint: 401 vs /login redirect

basics

~10 s

Define two SecurityFilterChain beans: one with securityMatcher("/api/**"), stateless + JWT + CSRF off, ordered first; one catch-all with sessions + form login + CSRF, ordered last. Order matters because the first matching chain wins.

solid answer

~30 s

Create two SecurityFilterChain beans. The API chain: http.securityMatcher("/api/**"), SessionCreationPolicy.STATELESS, CSRF disabled (safe because it's stateless/token-based), oauth2ResourceServer().jwt(), @Order(1). The UI chain: no securityMatcher (so AnyRequestMatcher catch-all), session-based, formLogin, CSRF enabled, @Order(2) so it's evaluated last. FilterChainProxy uses first-match, so the specific API chain must precede the catch-all — otherwise the catch-all swallows /api/** and the API gets session/CSRF/form-login semantics. Each chain is fully independent: its own authentication mechanism, session policy, CSRF setting, and exception handling. Verify via startup logs listing each chain's matcher, and test that /api/** returns 401 JSON (not a login redirect) and UI paths redirect to the login page.

code

java · 25 lines
java
@Configuration
@EnableWebSecurity
class MultiChainSecurity {

    @Bean @Order(1)
    SecurityFilterChain apiChain(HttpSecurity http) throws Exception {
        http.securityMatcher("/api/**")
            .sessionManagement(s -> s.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
            .csrf(csrf -> csrf.disable())
            .authorizeHttpRequests(a -> a.anyRequest().authenticated())
            .oauth2ResourceServer(o -> o.jwt(Customizer.withDefaults()))
            .exceptionHandling(e -> e.authenticationEntryPoint(
                new HttpStatusEntryPoint(HttpStatus.UNAUTHORIZED)));
        return http.build();
    }

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

go deeper

for a junior

Recognize you can have more than one SecurityFilterChain.

for a middle

Configure two chains with securityMatcher + @Order and know first-match wins.

for a senior

Justify per-chain session/CSRF/entry-point choices and avoid shadowing.

for a principal

Architect multiple isolated chains, defend ordering as an invariant, and specify verification (startup logs + tests for 401 vs redirect).

## Why multiple chains Different parts of an app have genuinely different security models. A JSON API for SPAs/mobile is typically **stateless** (bearer JWT, no server session, no CSRF token flow), while a server-rendered UI is **stateful** (HTTP session, form login, CSRF protection). A single chain can't cleanly express both, so you use **two independent `SecurityFilterChain` beans**, each selected by `FilterChainProxy` based on the request path. ## The design ```java @Bean @Order(1) SecurityFilterChain api(HttpSecurity http) throws Exception { http.securityMatcher("/api/**") .sessionManagement(s -> s.sessionCreationPolicy(SessionCreationPolicy.STATELESS)) .csrf(CsrfConfigurer::disable) .authorizeHttpRequests(a -> a.anyRequest().authenticated()) .oauth2ResourceServer(o -> o.jwt(Customizer.withDefaults())) .exceptionHandling(e -> e.authenticationEntryPoint( new HttpStatusEntryPoint(HttpStatus.UNAUTHORIZED))); // 401, not a redirect return http.build(); } @Bean @Order(2) // catch-all: no securityMatcher SecurityFilterChain web(HttpSecurity http) throws Exception { http.authorizeHttpRequests(a -> a .requestMatchers("/", "/login", "/css/**").permitAll() .anyRequest().authenticated()) .formLogin(Customizer.withDefaults()); // CSRF + sessions are on by default return http.build(); } ``` ### Ordering is the load-bearing decision `FilterChainProxy` iterates chains and takes the **first** whose `matches` returns true. The web chain has **no** `securityMatcher`, so it uses `AnyRequestMatcher` and matches *everything* — including `/api/**`. If `@Order(2)` weren't lower-priority than the API chain, the catch-all would shadow the API chain, and API requests would get form-login redirects and CSRF 403s instead of stateless 401s. Rule: **specific chains first, catch-all last.** ### Independence of chains Each selected chain is a complete, isolated security context: - **Session policy**: STATELESS vs session-based — they don't leak into each other. - **CSRF**: disabling CSRF on the API chain does *not* disable it for the UI chain. (Disabling CSRF is only safe because the API is stateless/token-based and doesn't rely on ambient cookie auth.) - **AuthenticationEntryPoint**: API returns 401 JSON via `HttpStatusEntryPoint`; UI redirects to `/login`. This distinction is a frequent real-world requirement. - **Authentication mechanism**: `oauth2ResourceServer().jwt()` vs `formLogin()`. ### Pitfalls and gotchas - **Shadowing** (above) — the #1 bug. A catch-all before a specific chain makes the specific chain dead. - **Overlapping securityMatchers**: if two chains both match a path, only the earlier one runs; the later never sees that path. - **Forgetting a catch-all**: paths matched by no chain run with *no* security filters (silently unsecured). Keep a final catch-all. - **`permitAll` still runs the chain**: a permitted endpoint is still processed by that chain's filters (CSRF, headers, etc.); permitAll only affects the authorization decision, not chain selection. - **Static resources**: either permitAll within a chain or give them a dedicated early chain; avoid accidentally forcing auth on assets. - **Verification**: read the startup log (`Will secure ... with [...]`) to confirm chain order and matchers; write tests asserting `/api/**` returns 401 (not 302 to login). ### When to split further Add more chains for genuinely distinct models (e.g. an actuator management chain, a webhook chain with signature auth). Keep each chain's `securityMatcher` non-overlapping and ordered most-specific-first, catch-all last.

  • Why is disabling CSRF acceptable on the API chain but not the UI chain?
    CSRF attacks exploit ambient credentials the browser sends automatically (session cookies). The stateless API authenticates with an explicit bearer token the attacker's site can't read or forge, so CSRF doesn't apply. The session-based UI relies on cookies, so it needs CSRF protection.
  • How would you make /api/** return a JSON 401 instead of redirecting to the login page?
    Give the API chain its own AuthenticationEntryPoint (e.g. HttpStatusEntryPoint(HttpStatus.UNAUTHORIZED) or a custom JSON-writing entry point) via exceptionHandling. Because chains are independent, this doesn't affect the UI chain's redirect-to-login behavior.
  • What breaks if the catch-all chain is ordered before the API chain?
    The catch-all's AnyRequestMatcher matches /api/** first, so the API chain never runs. API calls get form-login redirects (302 to /login) and CSRF 403s instead of stateless JWT 401s — a silent, hard-to-spot security/behavior regression.

saying these in an interview costs you the question

  • Putting the catch-all (no securityMatcher) chain before specific chains.
  • Believing CSRF disabled on one chain disables it globally.
  • Assuming permitAll skips the chain's filters (it only affects authorization).
  • Omitting a catch-all so some paths run with no security filters at all.

context