CSRF Tokens for a Cookie-Based SPA

The Double-Submit Cookie Pattern

Spring writes a random token into a readable cookie XSRF-TOKEN. JavaScript on your page reads it and sends it back in the X-XSRF-TOKEN header. A malicious site cannot read your cookies, so it cannot send the header, and Spring rejects its request with 403.

GET  /api/...            <- Set-Cookie: XSRF-TOKEN=9f2c...   (not HttpOnly)
POST /api/auth/refresh   -> Cookie: XSRF-TOKEN=9f2c...; shopease_rt=...
                            X-XSRF-TOKEN: 9f2c...          (added by JS)
CsrfFilter: header == cookie token ? continue : 403

Spring Security Configuration

Spring Security 6 uses a "BREACH-safe" masked token by default, which an SPA cannot read from a cookie as-is. The Spring Security reference recommends this small handler for SPAs (newer versions also offer a one-line csrf.spa() shortcut):

import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import org.springframework.security.web.csrf.*;
import org.springframework.util.StringUtils;
import java.util.function.Supplier;

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 protection for rendered pages
        csrfToken.get();                                  // load the token so the cookie is written
    }

    @Override
    public String resolveCsrfTokenValue(HttpServletRequest request, CsrfToken csrfToken) {
        String header = request.getHeader(csrfToken.getHeaderName());
        // header from JS = raw cookie value -> plain; form parameter -> masked -> xor
        return (StringUtils.hasText(header) ? this.plain : this.xor).resolveCsrfTokenValue(request, csrfToken);
    }
}

// in SecurityConfig
http.csrf(csrf -> csrf
        .csrfTokenRepository(CookieCsrfTokenRepository.withHttpOnlyFalse())
        .csrfTokenRequestHandler(new SpaCsrfTokenRequestHandler()));

Sending the Token from React

// axios reads the XSRF-TOKEN cookie and sends X-XSRF-TOKEN automatically.
// For cross-origin calls (axios 1.6+) you must opt in with withXSRFToken.
export const api = axios.create({
  baseURL: '/api',
  withCredentials: true,
  withXSRFToken: true,          // default names: cookie XSRF-TOKEN, header X-XSRF-TOKEN
});

// With fetch you do it yourself:
function getCookie(name) {
  const match = document.cookie.split('; ').find((c) => c.startsWith(name + '='));
  return match ? decodeURIComponent(match.split('=')[1]) : null;
}

fetch('/api/auth/refresh', {
  method: 'POST',
  credentials: 'include',
  headers: { 'X-XSRF-TOKEN': getCookie('XSRF-TOKEN') },
});

Example

Without and with the header:

curl -i -X POST localhost:8080/api/auth/refresh \
  --cookie "shopease_rt=Qm9x...; XSRF-TOKEN=9f2c6a1e-4b7d-4a3c-8e21-0c5b7d9e1f00"

curl -i -X POST localhost:8080/api/auth/refresh \
  --cookie "shopease_rt=Qm9x...; XSRF-TOKEN=9f2c6a1e-4b7d-4a3c-8e21-0c5b7d9e1f00" \
  -H "X-XSRF-TOKEN: 9f2c6a1e-4b7d-4a3c-8e21-0c5b7d9e1f00"

Example output

HTTP/1.1 403 Forbidden
{"status":403,"error":"Forbidden","message":"Invalid or missing CSRF token","path":"/api/auth/refresh"}

HTTP/1.1 200 OK
Set-Cookie: shopease_rt=Vb7L...; Path=/api/auth; Max-Age=604800; Secure; HttpOnly; SameSite=Strict
{"accessToken":"eyJhbGciOiJIUzI1NiJ9...","tokenType":"Bearer","expiresIn":900,"user":{...}}

Note

Note: JavaScript can only read cookies of its own host. If the SPA is on shopease.in and the API on api.shopease.in, the SPA cannot read the API's XSRF-TOKEN cookie. Either serve the API under the same origin (shopease.in/api, see the Real World Example) or set the cookie domain with repo.setCookieCustomizer(c -> c.domain("shopease.in")).