Cross-Site Request Forgery tricks a logged-in user’s browser into firing a state-changing request the user never intended. The browser attaches the victim’s session cookie automatically, the server sees a valid session, and the malicious action goes through. This tutorial builds a complete, working CSRF defense in Node.js and Express 5, using the maintained csrf-csrf package, hardened cookies, origin checks, and a real attack page so you can watch the protection block a forged request.

The old default, the csurf middleware, was archived by the Express team and carries the notice “This package is archived and no longer maintained.” Thousands of tutorials still reference it. This guide uses the modern replacement, csrf-csrf 4.0.3, which pulls roughly 192,000 weekly downloads (about 749,000 a month) on npm and implements the signed double-submit cookie pattern recommended in the OWASP CSRF Prevention Cheat Sheet. Every version, command, and code block here is current as of June 13, 2026.

Why CSRF Protection Still Matters in 2026

CSRF lost its dedicated slot in the OWASP Top 10 years ago. The 2021 list folded it into broader categories, and the 2025 revision keeps the trend, emphasizing Broken Access Control, Security Misconfiguration, Software Supply Chain Failures, and Cryptographic Failures. Plenty of developers read that as “CSRF is solved.” It is not. The Top 10 became category-based, not attack-name-based, and CSRF now lives inside session and authentication architecture rather than as a headline entry.

The reason the risk persists is structural. Any application that authenticates with cookies, the default for server-rendered apps and a common choice for single-page apps, is a candidate. Browsers send cookies on cross-site requests under specific conditions, and if your only check is “does this request carry a valid session cookie,” an attacker on another domain can forge the request. Modern browser defaults like SameSite=Lax shrink the attack surface, but they do not close it: GET-based state changes, relaxed cookie settings, and cross-site flows that require SameSite=None all reopen the door.

The damage from a successful CSRF attack maps directly to whatever the victim can do. Change an account email and you can trigger a password reset to an attacker-controlled inbox. Move money, change a shipping address, add an OAuth application, escalate a role, delete a resource: any authenticated, state-changing endpoint without a CSRF check is exploitable. Because the request originates from the victim’s own browser and session, server logs show a legitimate user performing a legitimate-looking action. That makes CSRF quiet, and quiet attacks are the ones that survive in production.

The fix is well understood and cheap to implement. You bind every state-changing request to a secret the attacker’s page cannot read or guess: a token tied to the session, echoed in a header or form field, and verified server-side. Layer in SameSite cookies, the __Host- prefix, and origin validation, and you get defense in depth that holds even when one layer is misconfigured. The rest of this tutorial wires all of it together into a project you can run.

How a CSRF Attack Actually Works

Walk through the mechanics before writing defenses, because the defense only makes sense once you have seen the attack. Imagine a banking app at bank.example.com that updates the account email with a POST to /account/email. The user authenticates, and the server issues a session cookie. From that point, every request the browser sends to bank.example.com includes that cookie, no matter who initiated the request.

Now the user, still logged in, visits an unrelated page the attacker controls. That page contains a hidden form pointed at the bank, set to auto-submit on load:

<!-- attacker-page.html, hosted on evil.example -->
<body onload="document.forms[0].submit()">
  <form action="https://bank.example.com/account/email" method="POST">
    <input type="hidden" name="email" value="[email protected]">
  </form>
</body>

When the page loads, the browser submits the form to the bank. Because the request targets bank.example.com, the browser attaches the victim’s session cookie automatically. The server receives a POST with a valid session and a new email address. Without a CSRF check, it updates the account. The attacker now owns the recovery email and, soon after, the account. The victim saw nothing but a blank page.

The attack works because of one asymmetry: the browser sends the cookie, but the attacker’s JavaScript running on evil.example cannot read any value from bank.example.com, thanks to the same-origin policy. That asymmetry is the lever every CSRF defense pulls. If the server demands a secret that only a genuine page served by bank.example.com could know, the forged form has no way to supply it. The attacker can make the browser send the cookie, but cannot make it send the token.

Two details matter for what comes next. First, GET requests must never change state. If /account/delete responds to a GET, an attacker forges it with a single <img src> tag and no form at all. Second, custom request headers like X-CSRF-Token cannot be set on a cross-site request by a plain HTML form; only same-origin JavaScript can attach them. That is why header-based tokens are a strong signal of a legitimate origin.

CSRF Defenses Compared: Tokens, Cookies, and Headers

Three families of defense exist, and a production app usually combines them. The synchronizer token pattern stores a per-session token server-side and compares it on every state-changing request. The double-submit cookie pattern stores a token in a cookie and expects the client to echo it in a header or body, verifying without server-side storage; the signed (HMAC) variant binds that token to the session so it cannot be forged. Cookie attributes like SameSite and the __Host- prefix shrink the attack surface at the browser layer, and origin or referer validation acts as a backup signal.

DefenseServer stateBest forStrengthNotes
Synchronizer tokenRequiredServer-rendered apps with sessionsStrongToken stored and compared server-side (csrf-sync)
Signed double-submit cookieNoneSPAs, distributed APIsStrongHMAC binds token to session (csrf-csrf)
Plain double-submit cookieNoneLegacy or simple setupsMediumBrittle if the token is unsigned
SameSite=Lax cookieNoneBaseline for all appsMediumChrome default since 2020
SameSite=Strict cookieNoneNo cross-site navigation neededHighCan break inbound links and SSO
Origin / Referer checkNoneDefense in depthMediumHeader can be stripped by privacy tools
Custom header requirementNoneSPA and API endpointsMedium-highBrowsers cannot set it cross-site

Which pattern you pick depends on architecture. If you already run server-side sessions and render HTML, the synchronizer token is the classic, strongest choice, and csrf-sync implements it. If you run an SPA or a stateless API where storing per-request tokens is awkward, the signed double-submit cookie is operationally simpler and just as safe when implemented correctly. This tutorial leads with csrf-csrf because it covers the widest range of modern apps, then shows the synchronizer alternative so you can choose deliberately.

One warning the csrf-csrf maintainers stress: the function that extracts the token from a request must read from exactly one place, a header or the body, never a loose fallback chain. The original csurf vulnerability stemmed from sloppy token extraction. Keep getCsrfTokenFromRequest explicit, and never write req.headers['x-csrf-token'] || req.body._csrf unless you fully understand the consequence.

Prerequisites and Versions

Pin your toolchain before writing code. CSRF middleware is sensitive to cookie handling and middleware order, and version drift causes subtle, hard-to-debug failures. Everything below is the current release line as of June 2026.

ToolVersionRole in this project
Node.js24.16.0 LTS (“Krypton”)JavaScript runtime; pin to current Active LTS
Express5.2.1Web framework (Express 5 is the new baseline)
csrf-csrf4.0.3Signed double-submit cookie CSRF protection
cookie-parser1.4.7Parses cookies so the middleware can read the token
express-sessionlatestProvides the session identifier the HMAC binds to
helmet8.2.0Sets defensive HTTP response headers

You need working knowledge of JavaScript and the request/response cycle, a terminal, and roughly 45 minutes. Confirm your runtime first. Node 24 is the current Active LTS line; Node 22 (“Jod”) is also in maintenance LTS and works fine if you cannot upgrade.

$ node --version
v24.16.0

$ npm --version
11.4.2

If node --version prints something older than v22, install the current LTS from nodejs.org or via a version manager before continuing. A short note on scope: csrf-csrf protects cookie-based sessions. If your SPA authenticates purely with a bearer token held in memory and sent in an Authorization header, the browser never attaches it automatically, so classic CSRF does not apply and you do not need this layer. The moment you store auth in a cookie, you do.

Step 1: Initialize the Express 5 Project

Create the project directory and install the exact dependencies. Pinning versions in this first command means your build matches the tutorial precisely.

$ mkdir csrf-demo && cd csrf-demo
$ npm init -y
$ npm install [email protected] [email protected] [email protected] \
    express-session [email protected]
$ npm pkg set type=commonjs
$ npm pkg set scripts.start="node app.js"

Generate three cryptographically strong secrets, one each for cookie signing, the session store, and the CSRF HMAC. Never reuse a single secret across all three, and never hardcode them in source. Node’s crypto module makes generation a one-liner:

$ node -e "console.log(require('crypto').randomBytes(32).toString('hex'))"
4f9c2a1e7b8d05f3a6c4e9b2d7108fa3c5e64b9d2017f8a3c6e4b9d2017f8a3c

Run it three times and drop the values into a .env file, then add that file to .gitignore. This project reads them through process.env; in development you can load them with Node’s built-in --env-file flag, no extra dependency required.

# .env  (never commit this file)
COOKIE_SECRET=4f9c2a1e7b8d05f3a6c4e9b2d7108fa3c5e64b9d2017f8a3c6e4b9d2017f8a3c
SESSION_SECRET=a1b2c3d4e5f60718293a4b5c6d7e8f901a2b3c4d5e6f70819a2b3c4d5e6f7081
CSRF_SECRET=9e8d7c6b5a4039281706f5e4d3c2b1a09f8e7d6c5b4a39281706f5e4d3c2b1a0
NODE_ENV=development

The skeleton is ready. The next steps build app.js incrementally so you understand each middleware before the CSRF layer goes on top.

The signed double-submit pattern binds each CSRF token to a session identifier via HMAC. That means you need a session and you need cookie parsing wired in the correct order. Order is not cosmetic here: csrf-csrf reads the token cookie through cookie-parser, and the HMAC reads the session id from express-session. If cookie-parser runs before express-session when sessions also use cookies, the two can collide. The rule from the maintainers: register cookie-parser after express-session.

// app.js
const express = require("express");
const session = require("express-session");
const cookieParser = require("cookie-parser");

const app = express();
const isProd = process.env.NODE_ENV === "production";

app.use(express.json());
app.use(express.urlencoded({ extended: false }));

app.use(
  session({
    secret: process.env.SESSION_SECRET,
    resave: false,
    saveUninitialized: false,
    cookie: {
      httpOnly: true,
      sameSite: "lax",
      secure: isProd, // requires HTTPS in production
    },
  })
);

// cookie-parser AFTER express-session
app.use(cookieParser(process.env.COOKIE_SECRET));

A few choices are deliberate. saveUninitialized: false avoids issuing a session to anonymous visitors who never log in, which keeps your store lean and avoids handing CSRF tokens to clients that do not need them. httpOnly: true stops client JavaScript from reading the session cookie, blunting XSS-driven session theft. sameSite: "lax" is the baseline that mirrors Chrome’s default behavior since 2020: cookies ride along on top-level navigations but not on cross-site sub-requests like a forged form POST.

In production, secure: true forces the cookie to travel only over HTTPS. Behind a reverse proxy such as Nginx or a load balancer, also set app.set("trust proxy", 1) so Express trusts the X-Forwarded-Proto header and issues secure cookies correctly. Skip that and your secure cookies silently fail to set behind TLS termination, a classic deployment trap covered in the troubleshooting section.

Now initialize csrf-csrf. Version 4 changed the API from version 3, so code copied from older tutorials will not run. The package exports a single factory, doubleCsrf, that takes a config object and returns the utilities you wire into your app. Only getSecret and getSessionIdentifier are required; the rest have sensible defaults.

// app.js (continued)
const { doubleCsrf } = require("csrf-csrf");

const {
  generateCsrfToken,     // call in a route to mint a token
  doubleCsrfProtection,  // the protective middleware
  invalidCsrfTokenError, // exported error for your handler
  validateRequest,       // low-level check, for custom middleware
} = doubleCsrf({
  getSecret: () => process.env.CSRF_SECRET,
  getSessionIdentifier: (req) => req.session.id,
  cookieName: isProd
    ? "__Host-psifi.x-csrf-token"
    : "psifi.x-csrf-token",
  cookieOptions: {
    httpOnly: true,
    sameSite: "strict",
    secure: isProd,
    path: "/",
  },
  size: 32,
  ignoredMethods: ["GET", "HEAD", "OPTIONS"],
  getCsrfTokenFromRequest: (req) => req.headers["x-csrf-token"],
});

Read each option, because every one carries a security implication.

OptionDefaultWhat it controls
getSecretRequiredReturns the HMAC secret (or an array for rotation)
getSessionIdentifierRequiredReturns the session id the token is bound to
cookieName__Host-psifi.x-csrf-tokenName of the token cookie; keep the __Host- prefix in prod
cookieOptionsstrict, secure, httpOnlyAttributes on the token cookie
size32Bytes of randomness in the token message
ignoredMethodsGET, HEAD, OPTIONSSafe methods that skip the check
getCsrfTokenFromRequestreq.headers["x-csrf-token"]Where the token is read from; keep it explicit

The __Host- cookie prefix deserves attention. A compliant browser refuses to accept a __Host- cookie unless it is Secure, has Path=/, and carries no Domain attribute, which locks the cookie to the exact host and blocks subdomain injection. The catch: __Host- requires HTTPS, so it cannot be set on plain http://localhost. That is why the config above swaps to a plain name in development and uses the prefixed name only in production. getSessionIdentifier returning req.session.id ties the token to the live session; rotate the session on login or privilege change and you must mint a fresh token at the same moment.

Step 4: Wire the Protection Middleware

With the utilities in hand, apply doubleCsrfProtection to the routes that change state. You have two placement choices: mount it globally so every non-safe request is checked, or attach it per-route for surgical control. Global mounting is safer because it fails closed: a new POST route you forget about is still protected by default.

// app.js (continued)

// Public, safe routes can live above the protection.
app.get("/", (req, res) => {
  res.send("CSRF demo up. GET /csrf-token to fetch a token.");
});

// Everything below is CSRF-protected for unsafe methods.
app.use(doubleCsrfProtection);

app.post("/account/email", (req, res) => {
  // Reached only when a valid CSRF token was supplied.
  res.json({ ok: true, email: req.body.email });
});

app.post("/account/delete", (req, res) => {
  res.json({ ok: true, deleted: true });
});

Because ignoredMethods includes GET, HEAD, and OPTIONS, the middleware lets read-only traffic through untouched while demanding a valid token on POST, PUT, PATCH, and DELETE. This is also why the rule “GET requests must never change state” is load-bearing: if you put a state change behind a GET, the middleware waves it through and your protection evaporates. Keep mutations on POST and its siblings.

When validation fails, the middleware throws invalidCsrfTokenError rather than crashing the process. Express 5 forwards thrown errors to your error-handling middleware, so the request becomes a clean 403 once you add the handler in Step 7. Until that handler exists, a failed check surfaces as a default 500, which is misleading during early testing; wire the handler before you start probing.

Step 5: Expose a CSRF Token Endpoint

A protected client needs a way to obtain a token. generateCsrfToken(req, res) does two things in one call: it sets the token cookie on the response and returns the token value for the client to echo back in the x-csrf-token header. Expose it on a safe GET route so the SPA can fetch it on load.

// app.js: place ABOVE app.use(doubleCsrfProtection) so it is
// reachable without already holding a token.
app.get("/csrf-token", (req, res) => {
  const csrfToken = generateCsrfToken(req, res);
  res.json({ csrfToken });
});

The double-submit name comes from what happens next: the token travels to the client in two places, the cookie (set by generateCsrfToken) and the JSON body. On the next state-changing request, the client returns the token in the x-csrf-token header. The middleware recomputes the HMAC from the cookie value plus the session id and the server secret, then compares it against the header value. A forged page on another origin can neither read the cookie nor compute a matching token, so it cannot satisfy both halves.

For server-rendered HTML you do not need a separate endpoint. Call generateCsrfToken(req, res) inside the route that renders the form and inject the value into a hidden field, then set getCsrfTokenFromRequest to read req.body._csrf instead of a header. After doubleCsrfProtection has run on a request, you can also call req.csrfToken() directly, which is equivalent to generateCsrfToken(req, res). Pick the header approach for SPAs and the hidden-field approach for traditional forms; do not mix both readers loosely in one extractor.

Step 6: Harden Cookies With SameSite and __Host-

Tokens are the primary defense; cookie attributes are the cheap, powerful backstop. Three settings do most of the work. SameSite controls whether a cookie rides along on cross-site requests. Secure restricts the cookie to HTTPS. HttpOnly hides the cookie from JavaScript. Used together they reduce the number of ways a forged request can carry the credentials it needs.

SameSite valueCross-site behaviorUse whenTrade-off
StrictNever sent cross-site, even on top-level navigationNo inbound cross-site links or SSO redirectsExternal links to logged-in pages look logged-out
LaxSent only on top-level GET navigationsDefault for most session cookiesAllows some cross-site GETs; pair with tokens
NoneAlways sent cross-siteCookie must work in a third-party contextRequires Secure; reopens CSRF risk, needs tokens

The config in Step 3 already sets the CSRF token cookie to SameSite=Strict, which is correct: that cookie never needs to travel from another site. The session cookie in Step 2 uses Lax so normal top-level navigation keeps users logged in. If your architecture forces SameSite=None anywhere, for example an embedded widget or a cross-domain SPA, treat the CSRF token as mandatory rather than optional, because the browser is no longer helping you.

The __Host- prefix is the final lock. Setting cookieName to __Host-psifi.x-csrf-token in production tells the browser to reject the cookie unless it is Secure, scoped to Path=/, and free of any Domain attribute. That defeats a whole class of cookie-fixation and subdomain-injection tricks where an attacker who controls sub.example.com tries to plant a cookie that example.com will read. Inspect the result with the browser devtools Application tab or a quick curl, and confirm the cookie carries Secure; HttpOnly; SameSite=Strict; Path=/.

Step 7: Validate Origin and Handle Token Errors

OWASP recommends origin validation as a second, independent signal: check the Origin header (or fall back to Referer) against an allowlist before the token check. It is not a sole defense, since privacy tools and some browsers strip these headers, but as a layer it catches forged requests cheaply. Add it as middleware ahead of the CSRF check.

// app.js (continued)
const ALLOWED_ORIGINS = new Set([
  "https://app.example.com",
  "http://localhost:3000",
]);

function verifyOrigin(req, res, next) {
  if (["GET", "HEAD", "OPTIONS"].includes(req.method)) return next();
  const source = req.headers.origin || req.headers.referer;
  if (!source) return res.status(403).json({ error: "missing origin" });
  try {
    const { origin } = new URL(source);
    if (!ALLOWED_ORIGINS.has(origin)) {
      return res.status(403).json({ error: "origin not allowed" });
    }
  } catch {
    return res.status(403).json({ error: "malformed origin" });
  }
  next();
}

app.use(verifyOrigin); // runs before doubleCsrfProtection

Now give failed CSRF checks a clean response. The middleware throws invalidCsrfTokenError; an Express 5 error handler at the end of the chain turns it into a 403 with a useful message instead of a stack trace. Place this last, after all routes.

// app.js (must be the LAST app.use)
app.use((err, req, res, next) => {
  if (err === invalidCsrfTokenError || err.code === "EBADCSRFTOKEN") {
    return res.status(403).json({ error: "invalid or missing CSRF token" });
  }
  console.error(err);
  res.status(500).json({ error: "server error" });
});

const PORT = process.env.PORT || 3000;
app.listen(PORT, () => console.log(`listening on ${PORT}`));

Returning a distinct 403 for CSRF failures helps your frontend react gracefully: it can fetch a fresh token and retry once, rather than logging the user out. Do not leak the expected token or secret in the error body. A generic “invalid or missing CSRF token” is all the client needs.

Step 8: Add Helmet and Security Headers

CSRF rarely travels alone. The same pages that need CSRF protection benefit from a Content Security Policy, clickjacking defense, and strict transport security, all of which helmet 8.2.0 sets in one line. A strong CSP reduces the XSS risk that would otherwise let an attacker read your CSRF token directly, which matters because no CSRF defense survives a successful XSS. Treat the two as partners.

// app.js (near the top, before routes)
const helmet = require("helmet");

app.use(
  helmet({
    contentSecurityPolicy: {
      directives: {
        defaultSrc: ["'self'"],
        scriptSrc: ["'self'"],
        objectSrc: ["'none'"],
        frameAncestors: ["'none'"], // blocks clickjacking
      },
    },
  })
);

if (isProd) app.set("trust proxy", 1); // honor X-Forwarded-Proto behind a proxy

The frameAncestors: ["'none'"] directive stops your app from being framed, which closes the clickjacking variant where an attacker overlays your real, token-bearing page inside an invisible iframe and tricks the user into clicking the genuine submit button. That attack bypasses CSRF tokens because the request really does come from your origin with a valid token; the only defense is refusing to be framed. Helmet also sets Strict-Transport-Security, X-Content-Type-Options: nosniff, and removes the X-Powered-By header that advertises Express.

For deeper background on how transport security and the padlock fit into this picture, see our explainer on HTTPS and TLS. CSRF protection assumes the connection itself is trustworthy; without TLS, an active network attacker can strip or rewrite both cookies and tokens, and none of the steps above hold.

Step 9: Integrate CSRF Tokens in a Frontend or SPA

The server is done. A real client needs to fetch a token, then attach it to every state-changing request. The pattern is two requests: one safe GET to /csrf-token on load, then the protected POST carrying the token in the x-csrf-token header. Crucially, both requests must send credentials so the cookie travels with them.

// client.js (runs in the browser, same origin as the API)
async function getCsrfToken() {
  const res = await fetch("/csrf-token", { credentials: "include" });
  const { csrfToken } = await res.json();
  return csrfToken;
}

async function updateEmail(newEmail) {
  const csrfToken = await getCsrfToken();
  const res = await fetch("/account/email", {
    method: "POST",
    credentials: "include",            // send the cookie
    headers: {
      "Content-Type": "application/json",
      "x-csrf-token": csrfToken,        // echo the token
    },
    body: JSON.stringify({ email: newEmail }),
  });
  if (res.status === 403) {
    // token expired or session rotated: fetch once and retry
    return updateEmail(newEmail);
  }
  return res.json();
}

Two integration details trip people up. First, credentials: "include" is mandatory; without it fetch omits cookies and the middleware sees no token cookie, returning 403 every time. Second, if your frontend and API live on different origins, you must configure CORS on the server to allow the frontend origin and set credentials: true, otherwise the browser blocks the cross-origin cookie. In that cross-origin case the CSRF token cookie also needs SameSite=None; Secure, which is exactly when the token defense earns its keep.

A common refinement is to fetch the token once at app startup, cache it in memory, and refresh it only on a 403. The retry-once branch above does that lazily. Avoid storing the token in localStorage; keep it in a JavaScript variable so an XSS payload has a smaller window to grab it. This mirrors the discipline we cover in our JWT authentication tutorial, where token storage choices drive the threat model.

Step 10: Test Your CSRF Protection With a Real Attack

Protection you have not attacked is protection you do not have. Test three cases: a request with no token (must fail), a request with a valid token (must pass), and a simulated cross-site forgery (must fail). Start the server, then drive it with curl using a cookie jar so the session and token cookies persist across calls.

$ node --env-file=.env app.js &
listening on 3000

# 1. No token, blocked at the origin gate
$ curl -s -X POST http://localhost:3000/account/email \
    -H "Content-Type: application/json" \
    -d '{"email":"[email protected]"}'
{"error":"missing origin"}

# 2. Fetch a token + cookie into a jar, then POST with both
$ curl -s -c jar.txt http://localhost:3000/csrf-token
{"csrfToken":"a3f1...d9"}

$ curl -s -b jar.txt -X POST http://localhost:3000/account/email \
    -H "Content-Type: application/json" \
    -H "x-csrf-token: a3f1...d9" \
    -H "Origin: http://localhost:3000" \
    -d '{"email":"[email protected]"}'
{"ok":true,"email":"[email protected]"}

The first call is rejected at the origin gate, the second succeeds with a matching cookie and header. Now simulate the forgery. Save the attacker page from the earlier section as attack.html, serve it from a different port, and open it while logged into the demo. The browser submits the form, attaches the session cookie, but cannot supply the x-csrf-token header or read the strict-same-site token cookie. The server answers 403.

# Simulate the attack: cookie present, but forged origin
$ curl -s -b jar.txt -X POST http://localhost:3000/account/email \
    -H "Content-Type: application/json" \
    -H "Origin: http://evil.example" \
    -d '{"email":"[email protected]"}'
{"error":"origin not allowed"}

# Even from an allowed origin, a forged page has no token:
$ curl -s -b jar.txt -X POST http://localhost:3000/account/email \
    -H "Content-Type: application/json" \
    -H "Origin: http://localhost:3000" \
    -d '{"email":"[email protected]"}'
{"error":"invalid or missing CSRF token"}

Both forgery attempts fail, the first at the origin check and the second at the token check. That layered failure is the point: an attacker who defeats one signal still faces the other. The same test-the-attack discipline appears in our two-factor authentication guide, where you verify the second factor actually blocks a stolen-password login.

Step 11: Rotate Secrets and Tokens Safely

Long-lived secrets are a liability. csrf-csrf supports rotation because getSecret can return an array: the first secret signs new tokens, and every secret in the array is accepted during validation. That lets you introduce a new secret without instantly invalidating tokens minted under the old one, then retire the old secret after the rotation window passes.

// rotation: newest secret first, old secret still valid for a window
getSecret: () => [
  process.env.CSRF_SECRET_CURRENT,
  process.env.CSRF_SECRET_PREVIOUS,
],

The other half of rotation is the session itself. Because each token is bound to req.session.id, any event that rotates the session also invalidates outstanding CSRF tokens. You must rotate the session on every authorization change: login, logout, and privilege elevation such as entering a “sudo” admin mode. Regenerating the session on login is also a defense against session fixation. The order matters: regenerate the session first, then mint the new CSRF token against the fresh id.

app.post("/login", (req, res, next) => {
  // ... verify credentials first ...
  req.session.regenerate((err) => {
    if (err) return next(err);
    req.session.userId = user.id;
    const csrfToken = generateCsrfToken(req, res); // bound to new session id
    res.json({ ok: true, csrfToken });
  });
});

If your app stores passwords, the same care for secret handling applies to your hashing layer. Our Argon2 password hashing tutorial covers parameter tuning and rotation for credentials, which pairs naturally with the session and token rotation here.

Step 12: The Synchronizer Token Alternative

If you run server-side sessions and render HTML, the synchronizer token pattern is the classic, arguably stronger choice, because the canonical token never leaves the server in cookie form: it is stored in the session and compared on submission. The maintainers of csrf-csrf ship a sibling package, csrf-sync, for exactly this. The API mirrors what you have already built, so switching is low-friction.

$ npm install csrf-sync

// app.js (synchronizer variant)
const { csrfSync } = require("csrf-sync");

const {
  generateToken,              // store a token in the session, return it
  csrfSynchronisedProtection, // the protective middleware
} = csrfSync({
  getTokenFromRequest: (req) => req.headers["x-csrf-token"],
});

app.get("/csrf-token", (req, res) => {
  res.json({ csrfToken: generateToken(req) });
});

app.use(csrfSynchronisedProtection);

The difference is where trust lives. The double-submit pattern is stateless: the server holds only the secret, and the token round-trips through a cookie. The synchronizer pattern is stateful: the canonical token sits in the session store, so there is nothing for an attacker to forge from the client side at all. The cost is that you must run a session store, which is fine for a monolith but adds coordination in a horizontally scaled, multi-node deployment where sessions need shared storage like Redis.

Choose by architecture, not by fashion. Stateless API or cross-origin SPA: signed double-submit with csrf-csrf. Server-rendered monolith with a session store: synchronizer with csrf-sync. Both are correct; the failure mode is mixing patterns half-heartedly or, worse, copying a deprecated csurf snippet that no longer reflects how either pattern should work.

Common CSRF Pitfalls to Avoid

These mistakes recur in real code reviews. Each one quietly disables protection while the app still appears to work.

  • Loose token extraction. Writing getCsrfTokenFromRequest: (req) => req.headers["x-csrf-token"] || req.body._csrf || req.query.csrf recreates the exact vulnerability that got csurf deprecated. An attacker who can influence any one of those sources can satisfy the check. Read from one explicit place.
  • State changes behind GET. Because GET is in ignoredMethods, a “logout” or “delete” implemented as a GET is completely unprotected and exploitable with a bare <img> tag. Keep every mutation on POST, PUT, PATCH, or DELETE.
  • Forgetting credentials on fetch. Omitting credentials: "include" means no cookie is sent, so the middleware returns 403 and developers “fix” it by disabling CSRF. The real fix is sending the cookie.
  • Using the deprecated csurf package. csurf 1.11.0 is archived and unmaintained. Copying its snippets onto Express 5 produces code that may run but no longer receives security fixes. Migrate to csrf-csrf or csrf-sync.
  • Wrong middleware order. Registering cookie-parser before express-session, or mounting doubleCsrfProtection before cookie-parser, breaks token reading. The chain is express-session, then cookie-parser, then the CSRF middleware.
  • Ignoring XSS. A single reflected or stored XSS lets an attacker read the token from your page and forge requests at will. CSRF tokens assume the page is not compromised; pair them with a Content Security Policy and output encoding.

Troubleshooting Common CSRF Errors

When a request that should pass returns 403, work through these in order. Most failures trace to cookies not being set, not being sent, or being read in the wrong place.

SymptomLikely causeFix
Every POST returns 403, even valid onesfetch missing credentials: "include"Add credentials so the cookie is sent
Cookie never appears in devtools__Host- prefix used over plain HTTPUse a non-prefixed name in dev; __Host- needs HTTPS
Works locally, 403 behind Nginxsecure cookie not set without proxy trustapp.set("trust proxy", 1) in production
Token valid then suddenly rejectedSession was rotated (login/logout)Mint a fresh token after session.regenerate
Cross-origin SPA always 403CORS lacks credentials: true or originAllow the exact origin and enable credentials
403 with cookie and header presentSession id changed between issue and useEnsure a stable session before generating the token
req.session is undefinedexpress-session registered after CSRFMount session middleware before the CSRF layer
Old tab fails after a deployServer CSRF_SECRET changed, no rotation arrayReturn both secrets from getSecret during rollover

A fast diagnostic: open devtools, go to the Application tab, and confirm the token cookie exists with the attributes you expect (Secure, HttpOnly, SameSite=Strict, Path=/). Then check the Network tab to confirm the request actually carries both the cookie and the x-csrf-token header. If the cookie is missing, it is a settings problem; if the header is missing, it is a client problem. Splitting the failure that way resolves most cases in minutes.

Advanced Tips for Production CSRF Defense

Beyond the working project, a few practices separate a demo from a hardened deployment. First, scope your origin allowlist tightly and load it from configuration, not source, so staging and production use different lists without code changes. An overly broad allowlist that matches any subdomain hands attackers a foothold if any single subdomain is compromised.

Second, treat CSRF as one layer in a stack, not a standalone fix. The strongest deployments combine signed double-submit tokens, SameSite cookies, the __Host- prefix, origin checks, a strict CSP, and short session lifetimes. No single control is sufficient: SameSite has cross-browser edge cases, origin headers can be stripped, and tokens fall to XSS. Layered, they cover each other’s gaps. For the broader picture of how these controls fit together, our practical security guide maps the full defensive surface.

Third, automate the test you wrote in Step 10. Turn the no-token, valid-token, and forged-request cases into an integration test that runs in CI, so a future refactor that accidentally moves doubleCsrfProtection above your token endpoint, or drops credentials: "include", fails the build instead of shipping. CSRF regressions are invisible in manual clicking because the happy path keeps working; only an explicit forgery test catches them.

Finally, log CSRF rejections with enough context (origin, path, method, timestamp) to spot probing, but never log the token or secret itself. A spike of 403s from one origin is a useful early signal that someone is testing your defenses. Feed those logs into whatever monitoring you already run; CSRF attempts that fail are still reconnaissance worth watching.

Frequently Asked Questions About CSRF Protection

Is the csurf package still safe to use in 2026?

No. csurf 1.11.0 carries an official deprecation notice and is archived by the Express team: “This package is archived and no longer maintained.” It receives no security fixes. Use csrf-csrf 4.0.3 for the double-submit pattern or csrf-sync for the synchronizer pattern. Both are actively maintained and designed for Express 5.

Do I need CSRF protection if I use JWTs?

It depends on where the JWT lives. If you store the JWT in memory and send it in an Authorization header, the browser never attaches it automatically, so classic CSRF does not apply. If you store the JWT in a cookie, the browser does send it automatically and you need CSRF protection just like any cookie session. The deciding factor is automatic cookie transmission, not the token format.

Does SameSite=Strict make CSRF tokens unnecessary?

Not reliably. SameSite=Strict blocks most cross-site cookie transmission, but it has cross-browser inconsistencies, breaks legitimate flows like inbound SSO redirects, and does nothing against same-site attacks or scenarios that require SameSite=None. OWASP treats SameSite as defense in depth, not a replacement for tokens. Keep both.

Why does my POST work in curl but fail in the browser?

Almost always a missing credentials: "include" on the fetch call, or a CORS configuration that does not allow credentials for a cross-origin request. In curl you pass cookies explicitly with -b, so the request carries them; the browser only sends cookies when you opt in with credentials. Add it and confirm the cookie appears in the Network tab.

What is the difference between double-submit and synchronizer tokens?

The synchronizer token is stored server-side in the session and compared on submission, which makes it stateful and very strong but requires a session store. The double-submit cookie stores the token in a cookie and verifies it without server storage, which is stateless and ideal for APIs and SPAs. The signed variant in csrf-csrf binds the token to the session via HMAC so it cannot be forged. Both are recommended by OWASP.

Can CSRF protection stop a clickjacking attack?

No. Clickjacking loads your real, token-bearing page inside a hidden iframe and tricks the user into clicking the genuine button, so the request carries a valid token. CSRF tokens cannot tell that interaction apart from a legitimate one. Defend against clickjacking separately with frame-ancestors 'none' in your CSP or the X-Frame-Options header, both of which helmet can set.

How long should a CSRF token live?

Tie its lifetime to the session. Because csrf-csrf binds the token to the session id, the token is naturally valid for as long as the session is, and it becomes invalid the moment the session rotates on login, logout, or privilege change. There is no separate expiry to tune; manage the session lifetime and the token follows.

Does HTTPS alone protect against CSRF?

No. HTTPS protects data in transit from eavesdropping and tampering, but a CSRF attack rides a legitimate, encrypted request from the victim’s own browser. The attacker never needs to read the traffic; they only need the browser to send the cookie. HTTPS is a prerequisite for secure cookies and the __Host- prefix, so it enables CSRF defenses, but it is not one by itself.

External references: the OWASP CSRF Prevention Cheat Sheet, the OWASP CSRF attack reference, the csrf-csrf source and docs, the archived csurf repository, PortSwigger’s CSRF academy, and the MDN Set-Cookie documentation.