React cookbook · Intermediate
Call a cookie-authenticated API with CSRF protection
Your SPA uses session cookies; send the CSRF token required by the backend on every mutating request.
Approach
Backends like Django, Laravel and Spring expose the CSRF token in a readable cookie (e.g., XSRF-TOKEN) or an endpoint. Read it and send it in a header (X-XSRF-TOKEN/X-CSRFToken) for POST/PUT/PATCH/DELETE, with credentials: "include" (or same-origin). Centralize this in one fetch wrapper.
Solution
function readCookie(name: string): string | null {
const match = document.cookie.match(new RegExp(`(?:^|; )${name}=([^;]*)`));
return match ? decodeURIComponent(match[1]) : null;
}
const SAFE = new Set(["GET", "HEAD", "OPTIONS"]);
export async function api<T>(path: string, init: RequestInit = {}): Promise<T> {
const method = (init.method ?? "GET").toUpperCase();
const headers = new Headers(init.headers);
headers.set("Accept", "application/json");
if (init.body && !headers.has("Content-Type")) headers.set("Content-Type", "application/json");
if (!SAFE.has(method)) {
const token = readCookie("XSRF-TOKEN");
if (token) headers.set("X-XSRF-TOKEN", token);
}
const res = await fetch(`/api${path}`, { ...init, method, headers, credentials: "include" });
if (!res.ok) throw new ApiError(res.status, await res.json().catch(() => null));
return res.status === 204 ? (undefined as T) : ((await res.json()) as T);
}
// await api("/profile", { method: "PATCH", body: JSON.stringify({ name }) });
Pitfalls
- Disabling CSRF on the backend instead of sending the token.
- Cross-origin APIs without proper CORS +
credentialsconfiguration. - Tokens stored in localStorage when cookies would be safer.
Variations
- Fetch the token from a
/csrfendpoint on app start when cookies aren't readable.
Further reading: OWASP CSRF Prevention Cheat Sheet