Implementing Double Submit CSRF Tokens in React
Symptom: state-changing requests from your React SPA fail with 403 Forbidden even though the user is logged in and the session cookie is present.
Exact Symptom & Operational Context
Developers typically encounter sudden 403 Forbidden errors on state-changing endpoints after migrating to cookie-based sessions or enforcing strict SameSite defaults. The browser silently attaches the session cookie per RFC 6265, but the backend rejects the request due to missing anti-forgery validation. This walkthrough sits inside the Mitigating CSRF Attacks in Modern SPAs guide in the Modern Authentication Fundamentals reference, which explains why stateful sessions require explicit request verification in decoupled frontend architectures.
sequenceDiagram
participant R as React SPA
participant B as Backend
Note over R,B: login establishes the pair
R->>B: POST /api/auth/login
B-->>R: Set-Cookie csrf_token + JSON token
Note over R,B: every mutating request
R->>B: PATCH /api/profile<br/>cookie + X-CSRF-Token header
B->>B: timingSafeEqual(cookie, header)
alt match
B-->>R: 200 OK
else mismatch or missing
B-->>R: 403 Forbidden
end
Common operational symptoms include:
- Intermittent failures during concurrent API calls due to race conditions in token hydration.
- Silent token desynchronization following hard page refreshes or service worker cache invalidation.
- React StrictMode double-rendering triggering HTTP interceptor race conditions, resulting in mismatched or undefined CSRF headers.
Root Cause Analysis
The core vulnerability stems from the browser’s automatic cookie attachment behavior. Browsers include session cookies in cross-origin requests, making it impossible for the backend to distinguish legitimate user actions from malicious cross-site form submissions. While SameSite=Lax mitigates basic top-level navigation attacks, it fails against subdomain exploits, preflighted requests, and sophisticated phishing flows.
As detailed in Mitigating CSRF Attacks in Modern SPAs, the double-submit pattern resolves this by requiring a synchronized, unpredictable secret that only the legitimate frontend can read and echo back. Root causes in React implementations typically involve:
- Race conditions during initial token hydration from server-rendered payloads.
- Improper Axios/Fetch interceptor scoping that fails to attach headers to all mutating methods.
- Backend validation logic that lacks cryptographic constant-time comparison, leaving it vulnerable to timing attacks.
- Failure to handle concurrent identical tokens or token rotation during session state transitions.
Step-by-Step Remediation & Implementation
1. Server-Side Token Generation & Cookie Configuration
Generate a cryptographically secure random token (minimum 32 bytes) upon session creation. Set it as a cookie with HttpOnly=false (required for JavaScript access), Secure=true, and SameSite=Strict. Return an identical copy in the initial JSON response payload.
// Node.js/Express Example
import crypto from "crypto";
app.post("/api/auth/login", (req, res) => {
const csrfToken = crypto.randomBytes(32).toString("hex");
res.cookie("csrf_token", csrfToken, {
httpOnly: false, // Must be readable by JS for double-submit
secure: process.env.NODE_ENV === "production",
sameSite: "strict",
maxAge: 3600000,
});
res.json({ csrfToken });
});
2. React State Management & Interceptor Setup
Store the token in a secure, in-memory React context or state management solution. Never use localStorage or sessionStorage for CSRF tokens in XSS-sensitive applications. Implement a centralized HTTP client interceptor to attach the token to all mutating requests.
// React + Axios Implementation
import axios from "axios";
import { createContext, useContext, useState } from "react";
const CSRFContext = createContext();
export const CSRFProvider = ({ children, initialToken }) => {
const [csrfToken, setCsrfToken] = useState(initialToken);
return (
<CSRFContext.Provider value={{ csrfToken, setCsrfToken }}>{children}</CSRFContext.Provider>
);
};
export const useCSRF = () => useContext(CSRFContext);
// Axios Interceptor
const api = axios.create({ baseURL: "/api" });
api.interceptors.request.use((config) => {
if (["post", "put", "patch", "delete"].includes(config.method?.toLowerCase())) {
const token = document.cookie.match(/csrf_token=([^;]+)/)?.[1];
if (token) config.headers["X-CSRF-Token"] = token;
}
return config;
});
3. Backend Validation Logic
Configure middleware to extract both the cookie and the X-CSRF-Token header. Perform a strict equality comparison using constant-time algorithms to prevent timing side-channels. Reject the request immediately if either value is missing or mismatched.
// Validation Middleware
import crypto from "crypto";
function validateCSRF(req, res, next) {
const cookieToken = req.cookies.csrf_token;
const headerToken = req.headers["x-csrf-token"];
if (!cookieToken || !headerToken || cookieToken.length !== headerToken.length) {
// Length check required: timingSafeEqual throws on unequal-length buffers
return res.status(403).json({ error: "CSRF token missing" });
}
// Constant-time comparison to prevent timing attacks
const isValid = crypto.timingSafeEqual(Buffer.from(cookieToken), Buffer.from(headerToken));
if (!isValid) {
return res.status(403).json({ error: "CSRF token mismatch" });
}
next();
}
4. Edge Case Handling
- Token Rotation: Invalidate and regenerate the token immediately upon login, logout, or privilege escalation.
- Concurrent Requests: Implement request deduplication or queueing if your architecture requires strict token single-use.
- SSR Hydration: Ensure server-rendered payloads inject the token into a
<meta>tag or initial state object to prevent hydration mismatches.
Security Implications & Threat Modeling
The double-submit pattern is highly effective against CSRF but inherently shifts the attack surface toward Cross-Site Scripting (XSS). If an attacker successfully executes arbitrary JavaScript within your origin, they can read the non-HttpOnly cookie or intercept the header injection mechanism.
Critical Mitigations:
- Enforce a strict Content Security Policy (CSP) with
default-src 'self'and disableunsafe-inline. - Sanitize all DOM insertions using libraries like DOMPurify. Eliminate
eval()and dynamicinnerHTMLassignments. - Implement defense-in-depth by pairing CSRF validation with strict CORS policies, origin header verification, and secure cookie attributes.
- Ensure tokens are never logged, cached in browser history, or exposed via
Refererheaders. UseReferrer-Policy: strict-origin-when-cross-origin. - Backend validation must strictly enforce cryptographic randomness and constant-time comparison to neutralize brute-force and timing attacks.
Prevention & Monitoring Hooks
Production deployments require continuous observability and automated lifecycle management to maintain CSRF integrity.
- Structured Logging: Tag all
403 Forbiddenresponses withcsrf_validation_failed. Log user agent, IP, endpoint, and token presence/absence for forensic analysis. - Automated Alerting: Configure threshold-based alerts for anomalous
403spikes. Sudden increases often indicate automated attack campaigns or misconfigured frontend interceptors. - Token Lifecycle Management: Enforce short TTLs for high-risk operations (e.g., financial transactions, password changes). Rotate tokens automatically on session renewal or idle timeout.
- Regression Testing: Integrate Playwright or Cypress E2E suites that simulate concurrent state mutations, SPA route transitions, and service worker cache evictions. Verify interceptor header injection across all mutating HTTP methods.
- Incident Runbooks: Maintain documented procedures for rapid token revocation, session invalidation, and cookie purging in the event of suspected XSS compromise or token leakage.
Frequently Asked Questions
Why does double-submit work if the attacker can also send the cookie?
The browser will attach the csrf_token cookie to a forged cross-origin request, but the attacker’s page on a different origin cannot read that cookie value to copy it into the X-CSRF-Token header. Validation compares the header against the cookie, so a request without a matching header fails closed. The same-origin policy is what makes the secret unreadable to the attacker, not the cookie itself.
Should I use a signed (HMAC) double-submit token instead of a raw random one?
For a single backend origin, a CSPRNG-generated random token compared with crypto.timingSafeEqual is sufficient. Add an HMAC binding (the token = value.HMAC(value, serverSecret)) when subdomains can set cookies on the parent domain — without it, a compromised blog.example.com could plant a cookie/header pair that the parent accepts. The HMAC ties the token to a server-held secret the subdomain does not have. If you already keep server-side session state, prefer the synchronizer token pattern, which avoids this class of issue entirely.
How do I stop React StrictMode double-rendering from sending undefined CSRF headers?
Read the token from document.cookie inside the Axios request interceptor at call time, not during render. Because the interceptor runs per request rather than per render, StrictMode’s double mount/unmount cannot leave a stale or undefined value on the request config. Keep React state only as a hydration hint; the cookie is the source of truth.
Does the CSRF cookie need HttpOnly?
No — and it must not. The double-submit pattern requires JavaScript to read the token and echo it in the header, so the CSRF cookie is intentionally HttpOnly=false. Your session cookie stays HttpOnly. Keep the CSRF cookie Secure and SameSite=Strict so it never leaves TLS and is not sent on cross-site requests, which gives you a second, independent layer of defense.
What about GET requests and file downloads?
Only validate the token on state-changing methods (POST, PUT, PATCH, DELETE). Safe, idempotent GET/HEAD requests must not mutate state, so they need no token — and forcing a header on them breaks simple navigations and <a href> downloads. If a GET endpoint changes state, the real fix is to make it a POST.
Related
- Mitigating CSRF Attacks in Modern SPAs — the full defense-in-depth model this React implementation plugs into.
- Protecting Cookie Sessions with the Synchronizer Token Pattern — the stateful alternative when you keep server-side session storage.
- Securing localStorage vs httpOnly Cookies — why the readable CSRF cookie does not reintroduce the XSS token-theft risk.