Skip to content

CSRF Protection

docs-bot edited this page Sep 28, 2026 · 2 revisions

CSRF Protection

Why it's needed

Cookie auth means the browser attaches your session cookie to cross-site requests too. A malicious page can submit a form to your API and the cookie rides along. That is CSRF.

Header auth (Authorization: Bearer) is immune — an attacker's page cannot set that header on your behalf. So CSRF protection matters when authMode: "cookie".

The double-submit pattern

  1. Your server sets a CSRF token in a readable (non-httpOnly) cookie.
  2. The client echoes it back in a header on state-changing requests.
  3. Your server compares the two and rejects mismatches.

An attacker's page can trigger a request, but the same-origin policy stops it from reading your cookie — so it cannot forge the header.

Your backend does the enforcement. This client automates the browser half.


Setup

const api = createClient({
  authMode: "cookie",
  xsrfCookieName: "csrftoken",     // the cookie your server sets
  xsrfHeaderName: "X-CSRF-Token",  // the header to mirror it into (default)
});

await api.post("/orders", body); // X-CSRF-Token attached automatically

That's the whole client-side configuration.

Framework header names

Backend Cookie Header
Django csrftoken X-CSRFToken
Laravel XSRF-TOKEN X-XSRF-TOKEN
Rails CSRF-TOKEN X-CSRF-Token
Spring Security XSRF-TOKEN X-XSRF-TOKEN
ASP.NET Core XSRF-TOKEN X-XSRF-TOKEN
// Django
createClient({ authMode: "cookie", xsrfCookieName: "csrftoken", xsrfHeaderName: "X-CSRFToken" });

// Laravel Sanctum
createClient({ authMode: "cookie", xsrfCookieName: "XSRF-TOKEN", xsrfHeaderName: "X-XSRF-TOKEN" });

Behaviour details

  • Only unsafe methods get the header: POST, PUT, PATCH, DELETE, plus the refresh request. GET is a safe method and is left alone.
  • Only trusted origins get the header: the baseUrl origin, the page origin and authOrigins. A request to any other host goes out without it.
  • An explicit per-request header always wins — you can override it anywhere.
  • Missing token → no header. The request still goes out; your server rejects it if it requires one.
  • A throwing getCsrfToken is caught and treated as "no token", so a broken provider can't break every request.
  • Header comparison is case-insensitive, so your explicit x-csrf-token beats the configured X-CSRF-Token.

When the token isn't in a readable cookie

A <meta> tag, an in-memory value fetched at boot, or worker mode — where document.cookie does not exist. Supply it directly:

createClient({
  getCsrfToken: () =>
    document.querySelector<HTMLMetaElement>("meta[name=csrf-token]")?.content,
});

getCsrfToken takes precedence over xsrfCookieName.

Worker mode

Web Workers have no document, so the worker can't read the cookie itself. When a request needs the token, the worker asks the page, which reads xsrfCookieName or calls your getCsrfToken and answers. Both options therefore work the same in worker mode, and the token is read fresh for every write and for the refresh request.

// Both work in worker mode.
createClient({ authMode: "cookie", xsrfCookieName: "csrftoken" });
createClient({ authMode: "cookie", getCsrfToken: () => readCookie("csrftoken") });

getCsrfToken may return a promise. One that doesn't settle within 5 seconds counts as "no token", so a stuck provider can't stall every write.

A small helper:

function readCookie(name: string): string | undefined {
  const escaped = name.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
  const m = document.cookie.match(new RegExp(`(?:^|;\\s*)${escaped}=([^;]*)`));
  if (!m) return undefined;
  try { return decodeURIComponent(m[1]); } catch { return m[1]; }
}

Bootstrapping the token

Many backends require you to fetch the cookie once before the first mutation.

// Laravel Sanctum
const api = createClient({
  baseUrl: "https://api.example.com",
  authMode: "cookie",
  xsrfCookieName: "XSRF-TOKEN",
  xsrfHeaderName: "X-XSRF-TOKEN",
});

await api.get("/sanctum/csrf-cookie"); // seeds the cookie
await api.post("/login", { email, password });
// Django
await api.get("/api/csrf/"); // an endpoint whose view calls get_token(request)

Fetching a token from an endpoint into memory:

let csrf: string | undefined;

const api = createClient({
  authMode: "cookie",
  getCsrfToken: () => csrf,
});

async function bootstrap() {
  const res = await api.get<{ token: string }>("/csrf", { skipAuth: true });
  csrf = res.data?.token;
}

Overriding per request

await api.post("/special", body, {
  headers: { "X-CSRF-Token": oneOffToken },
});

The client sees a header already set and leaves it alone.


Server-side checklist

The client half is one line. The server half is where the security lives:

  • Compare the cookie value against the header value on every unsafe method; reject on mismatch with 403.
  • Set the CSRF cookie with SameSite=Lax (or Strict) and Secure.
  • The CSRF cookie must not be httpOnly — the client has to read it. Your session cookie should be.
  • Rotate the CSRF token on login and on privilege changes.
  • Use a cryptographically random, per-session token.
  • Restrict CORS to explicit origins (never * with Access-Control-Allow-Credentials: true).
  • Prefer SameSite=Lax on the session cookie too; use None only when you genuinely need cross-site requests, and always with Secure.

When you don't need any of this

Using authMode: "header"? You don't need CSRF protection at all — an attacker's page cannot attach your Authorization header. Configuring xsrfCookieName in header mode is harmless but pointless.

Next: Web Worker Isolation

Clone this wiki locally