The Synchronizer Token Pattern

Chapter 5
The Synchronizer Token Pattern
The classic CSRF defence — generation, embedding, validation (and your login error)

Now the defences. The synchronizer token pattern is the oldest and most robust CSRF defence, and the one behind the "Invalid CSRF token" message you hit on your admin login. It works by attacking precondition #2 from Chapter 1 — forgeable parameters — by requiring every state-changing request to carry a secret the attacker cannot know.

The Core Idea

The server generates a random, unpredictable token, ties it to the user's session, and embeds it in every form it serves. A genuine request (from the server's own page) includes the token; a forged request (from evil.com) cannot, because the attacker can't read the token out of your page (Same-Origin Policy, Chapter 2). The server rejects any state-changing request whose token is missing or wrong.

Why it works: proof the request came from your own page
The cookie proves "this is the logged-in user" — but that's exactly what CSRF abuses. The token adds a second, independent proof: "this request was generated by a page I served to that user," because only such a page contains the correct token. The attacker has the user's cookie (ambient) but not the token (unreadable cross-site). Requiring both closes the gap the cookie alone left open.

The Lifecycle: Generate → Embed → Submit → Validate

Generate
On session start (or per request), the server creates a cryptographically random token and stores it server-side, associated with the session.
Embed
When rendering a form, the server injects the token as a hidden field (or a meta tag for JS to read).
Submit
The browser sends the token back with the form data — alongside the session cookie, which rides automatically.
Validate
The server compares the submitted token against the one stored for the session. Match → process; mismatch/missing → reject (the "Invalid CSRF token" error).

What It Looks Like in Code (Express)

// server renders a form with the token embedded app.get("/transfer", (req, res) => { const token = req.session.csrfToken; // generated at login/session start res.send(`<form method="POST" action="/transfer"> <input type="hidden" name="_csrf" value="${token}"> ...</form>`); }); // server validates on the state-changing request app.post("/transfer", (req, res) => { if (req.body._csrf !== req.session.csrfToken) { return res.status(403).send("Invalid CSRF token."); // <- your error! } // ...perform the transfer... });

The token must be sent in the request body or a header — never only in a cookie, because a cookie auto-rides on forged requests too (that's the whole problem). The defining property is that the token travels somewhere the attacker would have to read or write, which cross-site they cannot.

Per-Session vs Per-Request Tokens

Per-session tokenPer-request token
Lifetimeone token for the whole sessionnew token after every request
Prossimple; tolerant of back button & multiple tabstighter; limits token-replay window
Conslonger-lived secretfragile — back button & multiple tabs cause mismatches

This table is the crux of your login mystery. Per-request rotation is more secure but notoriously breaks the back button and multi-tab usage, because an old page holds a token the server has already rotated away from.

★ Your admin-login "Invalid CSRF token" — diagnosed
Here's what's almost certainly happening. Your login page is served with a CSRF token bound to your pre-login session. By the time you submit, the token no longer matches — most commonly because: (1) the login form was served from cache / bfcache (Firefox's back-forward cache is aggressive — which is exactly why you saw it in Firefox), so its embedded token is stale; (2) the session was regenerated (many apps rotate the session/token on login to prevent session fixation, invalidating the token the form was rendered with); or (3) a second tab or an earlier-loaded form rotated the per-request token. Clicking Back reloads a form whose token matches the current session, so the next submit succeeds. It's the defence firing on a stale token, not an attack — which is why it "seems fine." Chapter 10 covers the proper fixes (don't cache the form, issue the token after session regeneration, allow the token to refresh).

Generating a Good Token

The token's security rests entirely on being unguessable. Use a cryptographically secure random generator, not Math.random():

// Node: 32 random bytes, hex-encoded const token = require("crypto").randomBytes(32).toString("hex");

Compare tokens with a constant-time comparison (e.g. crypto.timingSafeEqual) to avoid leaking information through timing. And validate on every state-changing request — a single unprotected POST endpoint is all an attacker needs.

Common ways the synchronizer token is silently defeated
The pattern only protects what it covers. Frequent mistakes: (1) protecting POST but leaving a state-changing GET unguarded (Chapter 3); (2) putting the token only in a cookie the server reads back (it rides on forged requests too — that's not the synchronizer pattern, it's a misimplementation); (3) a weak/predictable token; (4) not validating on some endpoints; (5) reflecting the token in a way XSS can read (Chapter 4 — XSS reads the token and defeats it regardless). The pattern is robust; the implementation details are where it leaks.

Hands-On Exercises

Exercise 1

Implement a minimal synchronizer-token defence in Express: generate a token at session start, embed it as a hidden _csrf field, and validate it on a POST route with a constant-time comparison. Explain why the token must be in the body/header and not only in a cookie.

📄 View solution
Exercise 2

Explain, step by step, why your admin-login "Invalid CSRF token" error occurs and why the browser's Back button clears it. Identify the three likely root causes (stale cached form, session regeneration on login, per-request rotation) and why Firefox's bfcache makes it more visible.

📄 View solution
Exercise 3

For each flawed implementation, explain how an attacker defeats it: (a) the token is stored only in a second cookie and the server checks the two cookies match; (b) only POST routes validate the token while GET /delete?id= exists; (c) the token is generated with Math.random(). State the fix for each.

📄 View solution

Chapter 5 Quick Reference

  • Synchronizer token — server issues a random token tied to the session; every state-changing request must echo it back
  • Works by attacking forgeable parameters — the attacker has the cookie but can't read the token (cross-site)
  • Lifecycle: generate → embed (hidden field) → submit (body/header) → validate against the session's stored token
  • Token must travel in the body or a header, never only a cookie (a cookie auto-rides forged requests)
  • Per-session (simple, back-button friendly) vs per-request (tighter, but breaks back button & multi-tab)
  • Your login error = a stale token (cached/bfcached form, session regenerated on login, or per-request rotation); Back reloads a matching form
  • Generate with a CSPRNG (crypto.randomBytes), compare in constant time, validate on every state-changing route
  • Defeated by: unguarded GETs · token-only-in-cookie · weak token · uncovered endpoints · XSS (reads the token)
  • Next chapter: double-submit cookie & stateless tokens — defending without server-side token storage