Safeguard
Application Security

CSRF in Node.js: attack mechanics and modern mitigation

Express has never shipped CSRF protection in core, and its most popular middleware, csurf, was archived in 2022 — here's what actually replaces it.

Safeguard Research Team
Research
7 min read

Cross-site request forgery does not need a single line of injected script to work — it just needs a browser that automatically attaches cookies to requests, which is every browser, by design. A malicious page hosted anywhere on the internet embeds a form or an <img> tag pointing at your Node.js app's /transfer or /change-email endpoint; if the victim has an active session cookie, the browser attaches it, and your server has no way to tell the request apart from one the victim clicked on purpose. This is not a theoretical risk category with an asterisk: Chrome 80, shipped in February 2020, changed the default SameSite cookie attribute to Lax specifically because ambient cookie inclusion on cross-origin requests was such a widespread problem that browser vendors decided to fix it at the platform level rather than wait for every server framework to opt in. And in October 2022, the Express.js security working group formally flagged csurf — the module that had been the de facto standard CSRF middleware for the framework for close to a decade — for deprecation, leaving a real gap in the ecosystem's default guidance. This post walks through how CSRF actually works against a Node.js backend, what changed with csurf, and which patterns replace it in 2026.

How does a CSRF attack actually work?

A CSRF attack works by exploiting the browser's cookie jar, not a vulnerability in your JavaScript. Say a user is logged into bank.example.com, which sets a session cookie after login. The attacker hosts a page at evil.example containing an auto-submitting form: <form action="https://bank.example.com/transfer" method="POST"> with hidden inputs for amount and destination account, plus a script that calls .submit() on page load. When the victim visits that page in the same browser session, the browser sends the POST to bank.example.com and attaches the session cookie automatically — because cookies are scoped to a domain, not to which page initiated the request. The bank's server sees a validly authenticated request and processes the transfer. GET-based CSRF is even simpler: an <img src="https://bank.example.com/delete-account"> tag fires the request the instant the page renders, no user interaction required beyond loading the page. No script injection, no XSS, no credential theft — just a forged request riding on real, valid credentials.

What did Chrome's SameSite=Lax default actually change?

Chrome's SameSite=Lax default, which shipped in Chrome 80 in February 2020, changed cookie behavior so that cookies without an explicit SameSite attribute are withheld from most cross-site requests, including the classic CSRF form-POST pattern described above. According to Chromium's own developer documentation, Google briefly rolled back enforcement in April 2020, citing COVID-19-related site stability concerns for services suddenly under heavy load, then re-enabled it later that year. SameSite=Lax still allows top-level cross-site navigations (a user clicking a link) to carry cookies, and SameSite=None — needed for legitimate cross-site use cases like embedded widgets — requires the Secure flag to work at all. This is a meaningful defense-in-depth layer, but it is not a complete CSRF fix on its own: Lax still permits simple top-level GET navigations, and misconfigured subdomains under the same registrable domain remain same-site to each other regardless of the attribute. OWASP's CSRF Prevention Cheat Sheet explicitly lists SameSite cookies as a complementary defense, not a replacement for application-level tokens.

What is the double-submit cookie pattern and why does it fit Node.js APIs well?

The double-submit cookie pattern works by having the server set a random CSRF token as a cookie, and requiring the client to also send that same token back in a custom header or request body field on state-changing requests — a value an attacker's cross-origin form can never read or replicate because the browser's same-origin policy blocks JavaScript on evil.example from reading bank.example.com's cookies. The server simply compares the cookie value to the submitted value; if they match, the request came from a page that could actually read the cookie, which cross-origin attacker pages cannot do. OWASP's Cross-Site Request Forgery Prevention Cheat Sheet documents this alongside a stronger signed variant, where the token is an HMAC of the session identifier rather than a bare random value, removing the need for server-side token storage entirely. That statelessness is exactly why double-submit fits Node.js REST APIs and SPA backends well: there's no session-store lookup required on every request, which matters for horizontally scaled Express or Fastify services running behind a load balancer with no sticky sessions.

Why was Express's csurf middleware deprecated, and what replaced it?

Express's csurf middleware was deprecated because of fundamental limitations in cookie-based CSRF defenses combined with an unsustainable maintenance burden, not because of one specific disclosed CVE. The package's last release shipped January 19, 2020, and the Express organization's security working group formally raised the deprecation in its GitHub tracker (expressjs/security-wg, issue #8) around October 2022, with further discussion in the main expressjs/express repository (discussion #5491) confirming the module would not receive further updates. Because Express itself has never bundled CSRF protection in core — it has always been opt-in middleware — this left a real gap for new projects following older tutorials. Current guidance points teams toward actively maintained alternatives: the csrf-csrf package, which implements the signed double-submit pattern directly, or @dr.pogodin/csurf, a community-maintained fork that preserves the original synchronizer-token API for teams migrating existing code. Fastify, by contrast, ships and maintains its own official @fastify/csrf-protection plugin, so Fastify users never hit the same abandonment risk.

How should Origin and Referer header checks fit into a defense-in-depth strategy?

Origin and Referer header validation should sit alongside SameSite cookies and tokens as an additional, low-cost layer — not a standalone defense, because both headers can be absent in edge cases (some proxies and privacy tools strip Referer, and older clients may omit Origin on same-origin requests). The practical pattern is an allowlist check: on any state-changing request, an Express or Fastify handler reads req.headers.origin (falling back to req.headers.referer if origin is missing) and rejects the request outright if the value doesn't match the app's own scheme and host. This catches forged cross-origin requests even in the rare case a SameSite misconfiguration or an old browser lets a cookie through, and it costs a single string comparison per request. OWASP's cheat sheet recommends this exact combination — SameSite cookies as the baseline, a synchronizer or double-submit token for state-changing routes, and Origin/Referer validation as a defense-in-depth backstop — precisely because no single layer covers every browser version and network configuration in production.

What should a Node.js team actually implement in 2026?

A Node.js team in 2026 should treat CSRF defense as layered, not single-technique: set SameSite=Lax (or Strict where the UX allows it) plus Secure and HttpOnly on session cookies as the baseline; add a signed double-submit token via csrf-csrf for Express APIs or @fastify/csrf-protection for Fastify services on every state-changing route (POST, PUT, PATCH, DELETE); and validate the Origin header as a final backstop. NestJS, which sits on top of Express or Fastify, follows the same guidance — its own docs point to wiring in the underlying platform's CSRF middleware rather than providing a bespoke abstraction. Teams still running csurf in production aren't necessarily broken today, but they're depending on an archived package with no path to security patches, which is the same risk class as any other abandoned dependency in package.json — worth flagging in a dependency audit even without a specific CVE attached to it.

Never miss an update

Weekly insights on software supply chain security, delivered to your inbox.