Protecting Cookie Sessions with the Synchronizer Token Pattern

Your app already keeps server-side session state, so you want a CSRF defense that ties the anti-forgery token to that session rather than trusting a value the browser echoes back to itself. This page is part of the Mitigating CSRF Attacks in Modern SPAs guide, and it covers the synchronizer token pattern: a server-generated, per-session CSRF token held in the session store, embedded in forms and request headers, and validated on every unsafe HTTP method.

When to Choose the Synchronizer Token Pattern

There are two mainstream stateful-app CSRF defenses, and the right one depends on whether you already have a server-side session store.

  • The synchronizer token pattern stores the canonical token server-side, bound to the session record. The client must send a copy; the server compares the copy to the stored value. The attacker cannot forge a request because they cannot read the server-side token.
  • The double-submit cookie pattern stores no server-side token; it sends the token in both a cookie and a header and checks they match. It is the right choice when you are stateless or want to avoid touching the session store. The full React walkthrough is in implementing double-submit CSRF tokens in React.

Choose the synchronizer token pattern when you already maintain stateful sessions (a Redis/Postgres-backed session id in an HttpOnly cookie). You get a stronger guarantee than double-submit because the token never lives solely in a place client-side JavaScript or a sibling subdomain can write. The cost is a session-store read on every state-changing request — negligible when you are already reading the session to authenticate.

flowchart LR
    subgraph SYNC["Synchronizer token"]
        S1["Server mints token\nstores in session"]:::idp --> S2["Embeds in form\nor /csrf endpoint"]:::idp
        S2 --> S3["Client sends header\nX-CSRF-Token"]:::client
        S3 --> S4["Server compares to\nSTORED session token"]:::store
    end
    subgraph DBL["Double-submit"]
        D1["Server mints token\nno server copy"]:::idp --> D2["Sets readable cookie"]:::idp
        D2 --> D3["Client echoes cookie\nin header"]:::client
        D3 --> D4["Server compares\ncookie == header"]:::rs
    end
    classDef client fill:#fff0ee,stroke:#c0392b,stroke-width:2px,color:#1a1614
    classDef idp    fill:#eef0ff,stroke:#2c3e8c,stroke-width:2px,color:#1a1614
    classDef store  fill:#fffbec,stroke:#d4840a,stroke-width:2px,color:#1a1614
    classDef rs     fill:#ebf5fb,stroke:#2980b9,stroke-width:2px,color:#1a1614

Browsers attach cookies automatically to any request to their origin, including requests triggered by a malicious third-party page (RFC 6265 defines this ambient transmission). The server cannot tell a genuine form submission from a forged cross-site one using the session cookie alone, because both carry the same valid cookie. CSRF defenses inject a secret the attacker’s page cannot know or read. In the synchronizer pattern, that secret is held authoritatively on the server and the legitimate client proves possession of it; a cross-site attacker, unable to read the server-side value or the rendered page (the same-origin policy blocks reading the response), cannot supply it.

Implementation in TypeScript

Phase 1 — Issue the Token at Session Creation

Generate a CSPRNG token (≥32 bytes) and store it in the session record. With express-session, the token lives on req.session, so it persists in Redis alongside the session id and is never directly readable by the client.

import crypto from "node:crypto";
import type { Request, Response, NextFunction } from "express";

// Mint a per-session CSRF token if one isn't present.
export function issueCsrfToken(req: Request, _res: Response, next: NextFunction) {
  if (!req.session.csrfToken) {
    req.session.csrfToken = crypto.randomBytes(32).toString("base64url");
  }
  next();
}

// Expose the current token to the SPA via a same-origin endpoint.
// The response is readable only by the legitimate origin's JS.
export function csrfTokenEndpoint(req: Request, res: Response) {
  res.json({ csrfToken: req.session.csrfToken });
}

For a server-rendered form, embed it as a hidden field instead of (or in addition to) the endpoint:

export function renderTransferForm(req: Request, res: Response) {
  res.send(`<form method="POST" action="/transfer">
    <input type="hidden" name="_csrf" value="${req.session.csrfToken}" />
    <input name="amount" />
    <button>Send</button>
  </form>`);
}

Phase 2 — Send the Token from the Client

The SPA fetches the token once after login, holds it in memory (never localStorage), and attaches it to every unsafe request via a custom header. A custom header is itself a mild CSRF barrier because cross-site forms cannot set arbitrary headers, but the server still validates the value.

let csrfToken: string | null = null;

export async function loadCsrfToken(): Promise<void> {
  const res = await fetch("/api/csrf", { credentials: "same-origin" });
  csrfToken = (await res.json()).csrfToken;
}

export async function apiMutate(path: string, body: unknown): Promise<Response> {
  if (!csrfToken) await loadCsrfToken();
  return fetch(path, {
    method: "POST",
    credentials: "same-origin",
    headers: { "Content-Type": "application/json", "X-CSRF-Token": csrfToken! },
    body: JSON.stringify(body),
  });
}

Phase 3 — Validate on Every Unsafe Method

Validate only on state-changing methods (POST, PUT, PATCH, DELETE); safe methods (GET, HEAD, OPTIONS) must remain side-effect-free and are not checked. Compare the submitted token against the server-stored session token with a constant-time comparison.

import crypto from "node:crypto";

const UNSAFE = new Set(["POST", "PUT", "PATCH", "DELETE"]);

export function validateCsrf(req: Request, res: Response, next: NextFunction) {
  if (!UNSAFE.has(req.method)) return next();

  const stored = req.session.csrfToken;
  const sent = (req.headers["x-csrf-token"] as string) || req.body?._csrf;

  if (!stored || !sent || stored.length !== sent.length) {
    // Length guard: timingSafeEqual throws on unequal-length buffers.
    return res.status(403).json({ error: "csrf_failed" });
  }
  const ok = crypto.timingSafeEqual(Buffer.from(stored), Buffer.from(sent));
  if (!ok) return res.status(403).json({ error: "csrf_failed" });

  next();
}

Because the canonical token lives in the session, regenerate the CSRF token whenever you regenerate the session identifier — at login, MFA, and privilege elevation — so a token captured pre-authentication cannot be replayed afterward. This dovetails with regenerating session IDs after login: destroy the old session, mint a new id and a new CSRF token together.

Phase 4 — SameSite as Defense-in-Depth

The synchronizer token is your primary control, but set SameSite on the session cookie as a second, independent layer. SameSite=Lax stops the browser from sending the session cookie on cross-site POSTs entirely, so most forged requests never even reach token validation; upgrade to Strict for high-sensitivity admin surfaces.

app.use(session({
  name: "sid",
  cookie: { httpOnly: true, secure: true, sameSite: "lax" }, // RFC 6265 attributes
  // ...store config
}));

Do not rely on SameSite alone — its enforcement varies across browsers and is relaxed for top-level navigations, so the server-side token remains mandatory. Full attribute guidance lives in configuring secure cookie flags in production.

Security Implications

The synchronizer pattern’s strength is that the authoritative token is unreadable by the client and unforgeable by a cross-site attacker, because reading the response that contains it is blocked by the same-origin policy. Its main residual risk is XSS: a script running on your origin can call /api/csrf, read the token, and forge requests — so CSRF defense never substitutes for XSS prevention in auth workflows. A secondary risk is per-session token reuse across a long session; rotating the token on privilege change limits the replay window. Because the token is bound to the session, logging out (destroying the session) invalidates the token automatically — a property double-submit lacks, since its token is just a cookie value with no server anchor.

Prevention & Monitoring Hooks

  • Log csrf_failed with context (session id hash, endpoint, Origin, IP). A spike usually means a broken client interceptor or an active attack campaign.
  • Validate Origin/Referer server-side as a cheap third layer; reject mismatches before token comparison.
  • Test with E2E suites (Playwright/Cypress) that fire concurrent mutations and route transitions to catch token desync after navigation.
  • Assert token rotation on privilege change in CI: the CSRF token after login or MFA must differ from the pre-auth token.

Frequently Asked Questions

How is this different from the double-submit cookie pattern?

The synchronizer pattern keeps the authoritative token server-side in the session store and compares the client’s copy against it. Double-submit keeps no server copy — it sends the token in a cookie and a header and checks they match. Synchronizer is stronger because the token isn’t sitting in a client-writable cookie, but it requires a session-store read per request. Use double-submit when you’re stateless; see the double-submit React guide.

Do I need a new token per request, or is per-session enough?

Per-session is sufficient and far simpler; per-request tokens add single-use protection but break concurrent requests and back-button navigation. The practical middle ground is per-session with mandatory rotation on every privilege transition (login, MFA, elevation), which bounds the replay window without the operational pain of single-use tokens.

Does SameSite=Lax make the token redundant?

No. SameSite=Lax is strong defense-in-depth, but its enforcement varies across browsers, is relaxed for top-level GET navigations, and can be undermined by subdomain or DNS-rebinding scenarios. Treat it as a second layer that stops most forged requests early, and keep the server-side token as the authoritative check (RFC 6265 governs SameSite, but does not guarantee uniform behavior).

Where should the SPA store the token between requests?

In memory (a module variable or app state), refetched after login or on a 403. Never put it in localStorage or sessionStorage, which are readable by any XSS payload. Because the synchronizer token is validated against the server-side session, even an in-memory copy lost on refresh is cheaply re-fetched from your /api/csrf endpoint.

Why isn't a custom header alone enough to stop CSRF?

Cross-site HTML forms cannot set custom headers, so requiring X-CSRF-Token blocks the simplest attacks — but fetch/XHR from a misconfigured CORS endpoint, legacy plugins, or a permissive proxy can sometimes inject headers. The unguessable, server-validated token closes that gap; the custom header is just a convenient carrier, not the security boundary by itself.