skip to content

In Spring Security 6, how does CSRF token handling work (deferred tokens, XorCsrfTokenRequestAttributeHandler, BREACH), and what breaks in a SPA if you don't account for it?

level: seniorimportance: should knowfreq 40%

answer

  1. SS6 deferred/lazy CsrfToken -> cookie not written
  2. XorCsrfTokenRequestAttributeHandler = BREACH protection
  3. raw cookie vs XOR-encoded header mismatch -> 403
  4. CsrfCookieFilter calls getToken()
  5. SpaCsrfTokenRequestHandler: xor render, plain resolve

basics

~20 s

Spring Security 6 defers (lazily loads) the CsrfToken and, by default, XORs it with random bytes per request via XorCsrfTokenRequestAttributeHandler for BREACH protection. In a SPA this means the cookie holds the raw token but the header must be handled correctly, so you often add a filter to force token loading and a request handler that resolves the plain header value.

solid answer

~50 s

Spring Security 6 changed two things. First, the `CsrfToken` is **deferred** — loaded lazily via a `Supplier` only when accessed — for performance, so with `CookieCsrfTokenRepository` the cookie isn't written unless something reads the token. Second, the default `CsrfTokenRequestHandler` is `XorCsrfTokenRequestAttributeHandler`, which **randomizes (XORs) the token per response** to defeat **BREACH** (a compression side-channel that extracts secrets reflected in responses). The consequence for SPAs: the `XSRF-TOKEN` cookie contains the **raw** token, but the server, using the XOR handler, expects the request value to be resolvable. The common breakage is a 403 because (a) the cookie was never written (deferred token never loaded) or (b) the SPA sends the raw cookie value while the handler expects the encoded form. The documented fix is a `CsrfCookieFilter` that calls `csrfToken.getToken()` to force loading, plus a custom `SpaCsrfTokenRequestHandler` that uses XOR for rendering but the plain `CsrfTokenRequestAttributeHandler` to resolve the header value.

code

java · 12 lines
java
@Bean
SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
    http
        .authorizeHttpRequests(a -> a.anyRequest().authenticated())
        .csrf(csrf -> csrf
            .csrfTokenRepository(CookieCsrfTokenRepository.withHttpOnlyFalse())
            // keep BREACH-safe rendering but resolve the SPA's raw header value
            .csrfTokenRequestHandler(new SpaCsrfTokenRequestHandler()))
        // force the deferred token to load so the XSRF-TOKEN cookie is always written
        .addFilterAfter(new CsrfCookieFilter(), BasicAuthenticationFilter.class);
    return http.build();
}

go deeper

for a junior

Awareness that SS6 changed CSRF token handling and SPAs need extra setup is enough.

for a middle

Know that tokens are deferred and XOR-randomized, and that a SPA needs a readable cookie plus a header.

for a senior

Explain deferred tokens, the BREACH/XOR rationale, the raw-cookie vs encoded-header mismatch, and the CsrfCookieFilter + SpaCsrfTokenRequestHandler fix.

for a principal

Weigh keeping BREACH protection vs simplicity, standardize the SPA pattern across services, and reason about the compression side-channel threat model.

## Two Spring Security 6 behaviors to understand ### 1. Deferred (lazy) CSRF tokens In SS6 the `CsrfToken` is exposed as a **`Supplier<CsrfToken>` / `DeferredCsrfToken`**. The token is only materialized when something actually reads it, avoiding session/cookie work on requests that don't need it. **Consequence with `CookieCsrfTokenRepository`:** the `XSRF-TOKEN` cookie is written to the response **only if the token is accessed** during the request. A plain `GET /` that never touches the token leaves the SPA without a cookie → the next POST has no header → **403**. **Fix — force loading with a small filter:** ```java final class CsrfCookieFilter extends OncePerRequestFilter { @Override protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain chain) throws ServletException, IOException { CsrfToken token = (CsrfToken) request.getAttribute("_csrf"); token.getToken(); // materialize the deferred token so the cookie is written chain.doFilter(request, response); } } ``` ### 2. BREACH protection via XorCsrfTokenRequestAttributeHandler (default) **BREACH** is an attack against **HTTP responses compressed over TLS**: by observing compressed response sizes across many guesses, an attacker can extract a secret that is reflected in the response body — including a static CSRF token. SS6's default `XorCsrfTokenRequestAttributeHandler` **XORs the raw token with fresh random bytes on every render**, so the token value in the HTML/response differs each time and its compressed size leaks nothing. The raw token is still what's persisted; only the *rendered* value is randomized, and the handler can decode it on the way back. ### The SPA cookie mismatch Here's the subtlety that trips people up. `CookieCsrfTokenRepository` writes the **raw** token to the `XSRF-TOKEN` cookie. A SPA reads that raw value and sends it as the `X-XSRF-TOKEN` header. But the **default `XorCsrfTokenRequestAttributeHandler` expects the request value in encoded form** when resolved through the request attribute. The result is a **403** even though the client faithfully echoed the cookie. **Documented fix — a delegating handler:** render with XOR (keep BREACH protection) but resolve the **plain** header value when the SPA supplies it: ```java final class SpaCsrfTokenRequestHandler implements CsrfTokenRequestHandler { private final CsrfTokenRequestHandler plain = new CsrfTokenRequestAttributeHandler(); private final CsrfTokenRequestHandler xor = new XorCsrfTokenRequestAttributeHandler(); @Override public void handle(HttpServletRequest request, HttpServletResponse response, Supplier<CsrfToken> csrfToken) { this.xor.handle(request, response, csrfToken); // BREACH-protected rendering } @Override public String resolveCsrfTokenValue(HttpServletRequest request, CsrfToken csrfToken) { String header = request.getHeader(csrfToken.getHeaderName()); // SPA sends the raw token via header -> resolve with the plain handler; // server-rendered form param -> resolve with XOR handler return (StringUtils.hasText(header) ? this.plain : this.xor) .resolveCsrfTokenValue(request, csrfToken); } } ``` ### Wiring it together ```java http.csrf(csrf -> csrf .csrfTokenRepository(CookieCsrfTokenRepository.withHttpOnlyFalse()) .csrfTokenRequestHandler(new SpaCsrfTokenRequestHandler())) .addFilterAfter(new CsrfCookieFilter(), BasicAuthenticationFilter.class); ``` ## Simpler-but-weaker alternative Setting `csrfTokenRequestHandler(new CsrfTokenRequestAttributeHandler())` (plain, no XOR) makes the raw cookie/header match trivially but **gives up BREACH protection**. The delegating handler keeps both properties, which is why the docs recommend it. ## Gotchas summary - Missing cookie on first load → deferred token never materialized → add `CsrfCookieFilter`. - 403 despite correct-looking header → XOR handler vs raw cookie mismatch → use `SpaCsrfTokenRequestHandler`. - Don't 'fix' it by disabling CSRF if you use cookie auth. - `withHttpOnlyFalse()` is required so the SPA can read the cookie; the auth cookie stays HttpOnly.

  • What problem does XorCsrfTokenRequestAttributeHandler solve, and how?
    BREACH — a TLS-compression side-channel that extracts secrets reflected in responses. It XORs the CSRF token with fresh random bytes on each render so the reflected value (and its compressed size) changes every response, leaking nothing.
  • Why might a SPA get a 403 even though it copies the XSRF-TOKEN cookie into the header?
    The cookie holds the raw token but the default XOR handler expects an encoded value when resolving, causing a mismatch. Use a SpaCsrfTokenRequestHandler that resolves the plain header value while still rendering with XOR.
  • Why might the XSRF-TOKEN cookie be missing entirely on first load?
    SS6 defers token materialization; if no code reads the token during the request, CookieCsrfTokenRepository never writes the cookie. A CsrfCookieFilter that calls csrfToken.getToken() forces it.

saying these in an interview costs you the question

  • Thinking CSRF tokens are static per session in SS6 by default (they are XOR-randomized per render)
  • Fixing the SPA 403 by disabling CSRF while using cookie auth
  • Assuming the cookie is always written even if the token is never accessed
  • Believing setting the plain handler is free — it drops BREACH protection

context