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 + credentials configuration.
  • Tokens stored in localStorage when cookies would be safer.

Variations

  • Fetch the token from a /csrf endpoint on app start when cookies aren't readable.

Further reading: OWASP CSRF Prevention Cheat Sheet

Reference: https://cheatsheetseries.owasp.org/cheatsheets/Cross-Site_Request_Forgery_Prevention_Cheat_Sheet.html

More React recipes