# 6. CSRF for SPAs, and what `spa()` actually assigns *Prev: [5. The /error dispatch](05-the-error-dispatch.md) · Next: [7. SameSite](07-samesite.md)* ## The recipe that stopped working in 6.0 ```java http.csrf(csrf -> csrf.csrfTokenRepository(CookieCsrfTokenRepository.withHttpOnlyFalse())) ``` Every SPA tutorial written before Spring Security 6 ends with that line. Three separate things go wrong with it now, and [`docs/output/08-csrf-naive.txt`](output/08-csrf-naive.txt) walks through all three in one transcript. **1. The bootstrap GET sets no cookie.** Since 6.0 the token is *deferred*: `CsrfFilter` puts a `Supplier` in a request attribute and only materialises it if something dereferences it. A `GET` on a JSON endpoint dereferences nothing. So the SPA starts up, sees no `XSRF-TOKEN` cookie, and its first mutating request has nothing to send. **2. Sending the raw cookie value fails.** The default handler is `XorCsrfTokenRequestAttributeHandler`, added as a BREACH mitigation. It expects the value in the header to be XOR-masked. `CookieCsrfTokenRepository` writes the **raw** token into the cookie. So the SPA reads a raw value, sends a raw value, and the handler tries to unmask it. 403. **3. The cookie is not sent cross-site anyway.** Chapter 7. ## What `spa()` is Spring Security 7.0 added `CsrfConfigurer.spa()`. Its entire bytecode: ``` 0: aload_0 1: invokestatic // CookieCsrfTokenRepository.withHttpOnlyFalse() 4: putfield // Field csrfTokenRepository 7: aload_0 8: new // class CsrfConfigurer$SpaCsrfTokenRequestHandler 12: invokespecial // 15: putfield // Field requestHandler 18: aload_0 19: areturn ``` Two unconditional assignments. `SpaCsrfTokenRequestHandler` holds two delegates: ```java private final CsrfTokenRequestAttributeHandler plain = new CsrfTokenRequestAttributeHandler(); private final CsrfTokenRequestAttributeHandler xor = new XorCsrfTokenRequestAttributeHandler(); // constructor: xor.setCsrfRequestAttributeName(null); ``` `handle(..)` always delegates to `xor`. `resolveCsrfTokenValue(..)` picks `plain` when the request carries the header and `xor` otherwise: ```java String headerValue = request.getHeader(csrfToken.getHeaderName()); return (StringUtils.hasText(headerValue) ? this.plain : this.xor) .resolveCsrfTokenValue(request, csrfToken); ``` So a SPA reading the cookie and echoing it in `X-XSRF-TOKEN` compares raw against raw and succeeds, while a `
` post keeps the BREACH masking on the hidden field. Both work, from one configuration. ## The part nobody documents: why the cookie now appears on the GET That `xor.setCsrfRequestAttributeName(null)` looks like a detail. It is the fix for problem 1. `CsrfTokenRequestAttributeHandler.handle` wraps the supplier in a `SupplierCsrfToken` and sets two request attributes. The key for the second one is the configured attribute name — or, when that is `null`, `csrfToken.getParameterName()`. Calling `getParameterName()` on a `SupplierCsrfToken` **dereferences the supplier**. The token is generated, the repository saves it, and the `Set-Cookie` header goes out. The eager rendering is a side effect of needing a string for a map key. It is real, it is load-bearing, and [`docs/output/10-csrf-spa.txt`](output/10-csrf-spa.txt) shows the cookie arriving on the bootstrap `GET` where `08` showed nothing. ## `spa()` discards what you configured before it Because the two assignments are unconditional: ```java .csrf(csrf -> csrf.csrfTokenRepository(myRepository).spa()) // myRepository is gone .csrf(csrf -> csrf.spa().csrfTokenRepository(myRepository)) // this one wins ``` The `spaorder` profile asks for a cookie named `MY-CSRF` and a header named `X-CSRF-TOKEN`; [`docs/output/11-spa-ordering.txt`](output/11-spa-ordering.txt) shows `XSRF-TOKEN` coming back instead. Tracked as [spring-security#18718](https://github.com/spring-projects/spring-security/issues/18718). ## A CSRF rejection does not look like a CSRF rejection `CsrfFilter` logs, at DEBUG: ``` o.s.security.web.csrf.CsrfFilter : Invalid CSRF token found for http://localhost:8080/api/data ``` and hands off to its `AccessDeniedHandler`, which calls `sendError(403)`. What the client receives, in a Basic-authenticated API with no `/error` chain, is **401 with `WWW-Authenticate: Basic`** — chapter 5. Every hour spent checking credentials after a 403-that-is-a-401 is spent on the wrong thing. Turn on `logging.level.org.springframework.security.web.csrf: DEBUG` before anything else. ## Do you need CSRF at all? If the API authenticates with a `Bearer` token held in memory and never with a cookie, then no: there is no ambient credential for a third-party page to ride on, and `csrf.disable()` is correct rather than lazy. If any part of the session lives in a cookie — including a `HttpOnly` refresh cookie — then yes, and `spa()` is the shortest correct configuration. --- *Prev: [5. The /error dispatch](05-the-error-dispatch.md) · Next: [7. SameSite](07-samesite.md)*