1
0
Files
spring-security-demo/cors-csrf/docs/06-csrf-for-spas.md
2026-08-28 10:16:21 +05:30

5.0 KiB

6. CSRF for SPAs, and what spa() actually assigns

Prev: 5. The /error dispatch · Next: 7. SameSite

The recipe that stopped working in 6.0

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 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<CsrfToken> 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 // <init>
15: putfield      // Field requestHandler
18: aload_0
19: areturn

Two unconditional assignments. SpaCsrfTokenRequestHandler holds two delegates:

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:

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 <form> post keeps the BREACH masking on the hidden field. Both work, from one configuration.

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 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:

.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 shows XSRF-TOKEN coming back instead. Tracked as spring-security#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 · Next: 7. SameSite