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 : 403Spring 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")).