
Most CSRF bugs are not exotic crypto. They are a state-changing request the browser is willing to send with cookies attached, and a server that trusts the cookie alone.
If your login session lives in a cookie, a page on another origin can often submit a form, fire a credentialed fetch, or navigate to an endpoint that deletes a user, changes an email, or moves money. The cookie goes along. SameSite helps on some of those paths. It does not replace an origin check or a CSRF token on the ones that still fire.
The usual mistake is installing csurf, copying a 2016 tutorial, and assuming the package is still the answer. csurf is unmaintained. The current job is to name the cookie, decide SameSite and CSRF together, and test the cross-site POST you think is blocked.
This page is that decision for Express and the wider web stack: the cookie flags, the token pattern that still works, and the cases where SameSite is not enough.
csurf 1.11.0 last published on 19 January 2020. Express TC deprecated it on 16 May 2025. Five years, and the install line is still the one old tutorials paste. Cross-site request forgery is still the browser attaching a cookie to a request the user did not mean.
Chrome treated cookies with no SameSite as Lax starting with the Chrome 80 rollout in February 2020. The Chromium SameSite Updates page dates stable 80 to 4 February 2020 and the enforcement wave to the week of 17 February 2020. That default closed the old cross-site POST-with-cookies shape for most people. It did not close GET state changes, sibling-subdomain requests, or clients that never send Fetch Metadata.
The practical order is this. Check Sec-Fetch-Site first. Keep SameSite=Lax as a belt. Keep a token only for pre-auth and for the browsers that omit the header. For the rest of the request-and-object surface, keep the secure coding checklist next to this page, and read XSS before you trust any token the page can already see.
Watch one cookie-authenticated POST. Lax is the first fork: the cookie either stays home or it rides. Then the server reads the header the browser set, not a token you minted.
SecureCoding
Why CSRF still exists after SameSite defaults
CSRF is CWE-352. The server sees a cookie it minted and treats the request as the user. The user never submitted that form. A cookie-authenticated app that changes state is the whole surface. Bearer tokens in Authorization are not auto-attached, so they fall out of this page. Cookies do not.
SameSite defaults did three things and left three holes:
- Cross-site POST lost the cookie. A foreign page cannot ride a Lax session cookie on POST, PUT, PATCH, or DELETE. That is the win from 2020.
- Top-level GET still sends it. A link, a bookmark, an address-bar navigation, a
window.locationassignment. If any of those URLs mutates state, Lax never sees a problem. - A same-site request can still be a different origin.
https://app.example.comandhttps://cdn.example.comare same-site. A cookie without a__Host-prefix can be written from a sibling. Lax and Strict both send it.
The control is a header the browser sets and your server reads, plus a cookie flag that matches the threat, plus a token only where those two cannot reach.
How SameSite=Lax actually works (and its exact gaps)
MDN’s Set-Cookie page sends a Lax cookie on a cross-site request only when both are true: the request is a top-level navigation, and the method is safe (GET, HEAD, OPTIONS). fetch(), a subframe, and an image request do not get it. A click from another site onto yours does.
Express 5.1.0 res.cookie passes the options object to cookie.serialize. sameSite is one of those options. Set it on purpose. Do not rely on the browser default if you can write the attribute.
// Express 5.1.0: session cookie, host-bound, Lax belt
res.cookie("__Host-session", sid, {
httpOnly: true,
secure: true,
sameSite: "lax",
path: "/",
});
Gaps that survive an explicit Lax:
- Any state-changing GET.
method-override3.0.0 defaultsoptions.methodsto['POST']. Leave that default. If you passmethods: null, a top-level GET can be rewritten into POST after the cookie has already been attached. - Default Lax is looser than explicit Lax. MDN still documents a two-minute window where a cookie that inherited Lax as the default is also sent on POST. Treat the two-minute window as something you do not rely on. The cheap fix does not need that date: set
SameSite=Laxyourself. - Sibling hosts share a site. Covered in the Gitpod section below.
__Host-is the prefix that stops a sibling from planting a cookie on you. - Client-side CSRF. Your own JavaScript reads a hash or a query and fires a same-origin POST. SameSite never runs. That is an input-validation bug. See the input validation guide.
Sec-Fetch-Site: the defense that replaced tokens
MDN marks Sec-Fetch-Site Baseline widely available since March 2023. The browser sets it. Frontend JavaScript cannot. Values are same-origin, same-site, cross-site, and none (typed URL, bookmark, dragged file).
OWASP current CSRF cheat sheet says a modern-browser app may rely on Fetch Metadata plus a fallback for the clients that omit it. The policy that matches the call on this page is below.
MDN Sec-Fetch-Site page is the first-party reference for the values above.
const SAFE = new Set(["GET", "HEAD", "OPTIONS"]);
function csrfAllowed(req) {
const site = req.get("sec-fetch-site");
if (site === "same-origin" || site === "none") return true;
if (site === "cross-site" && !SAFE.has(req.method)) return false;
if (site === "same-site" && !SAFE.has(req.method)) return false;
if (!site) return false;
return SAFE.has(req.method);
}
const ALLOWED = new Set([process.env.APP_ORIGIN]); // https://app.example.com
function originAllowed(req) {
const origin = req.get("origin");
if (origin) return ALLOWED.has(origin);
const referer = req.get("referer");
if (!referer) return false;
try {
return ALLOWED.has(new URL(referer).origin);
} catch (err) {
return false;
}
}
app.use((req, res, next) => {
if (SAFE.has(req.method)) return next();
res.set("Vary", "Sec-Fetch-Site, Sec-Fetch-Mode");
const site = req.get("sec-fetch-site");
if (site) {
if (csrfAllowed(req)) return next();
res.status(403).send("Forbidden");
return;
}
if (originAllowed(req)) return next();
res.status(403).send("Forbidden");
});
Safe GET, HEAD, and OPTIONS skip the header write. Vary and the deny only run on POST, PUT, PATCH, and DELETE. When Sec-Fetch-Site is absent, the fallback checks Origin, then Referer if Origin is missing, against a configured allowlist. Missing both fails closed. Do not fail open on a mutating cookie request just because a client omitted the header. Native apps and first-party curl jobs can send Sec-Fetch-Site: none on purpose.
The fetch-metadata package on the registry is still 1.0.0, dated 24 January 2022. No later release. Prefer the check above.
Allow same-site only after you have listed every hostname on the registrable domain and decided you trust them. The default above treats same-site like cross-site for POST and friends. That is the conservative reading of the OWASP sibling-subdomain note.
Tokens in 2026: csurf is dead, here is what to use
A token is leftover work in 2026. Keep one on login and password-reset, and on any client that omits Fetch Metadata. Do not add csurf to a new app. The replacements below are the ones with a 2025 release.
The package your tutorial still names was retired twice. As of the current csurf npm page, It still lists 1.11.0, published 19 January 2020, with a readme that still shows the old install line. The GitHub repo banner says archived, no longer actively maintained. The Express TC blog dated 16 May 2025 is the official deprecation. Those two first-party years do not match.
The 2022 thread is still on Express discussions. Doug Wilson archived the module after a Snyk advisory and a pile of reports that were mostly the limits of cookie CSRF, not a single CVE in the library. Snyk later lowered the severity. The archive stayed. Three years later the TC made the deprecation official. The tutorials did not move.
Swap it for the Sec-Fetch-Site check plus csrf-csrf 4.0.3 or csrf-sync 4.2.1. The archived csurf repository is the citation. Leave it out of package.json.
csrf-csrf 4.0.3 updated 27 May 2025 is signed double-submit. csrf-sync 4.2.1 updated 10 May 2025 is the synchronizer token for express-session.
Read the token from a header or a body field. Never from the cookie. The naive matching-strings pattern is the one a sibling host can mint.
First-party pages: Express spring cleaning, csrf-csrf 4.0.3, and the OWASP CSRF Prevention Cheat Sheet.
The dead package, so you can recognize it in an old tutorial:
// BAD: do not copy this into a new app
// const protect = require("csurf")({ cookie: true })
The replacement factory, csrf-csrf 4.0.3:
const { doubleCsrf } = require("csrf-csrf");
const { doubleCsrfProtection, generateCsrfToken } = doubleCsrf({
getSecret: () => process.env.CSRF_SECRET,
getSessionIdentifier: (req) => req.session.id,
cookieName: "__Host-psifi.x-csrf-token",
});
CSRF against JSON APIs and why Content-Type checks fail
A browser form can POST application/x-www-form-urlencoded, multipart/form-data, or text/plain without a CORS preflight. application/json cannot. Teams then “check Content-Type equals application/json” and call the API done.
That check fails when the parser is sloppy. express.json() is fine if you never also parse text/plain as JSON. A custom body reader that JSON.parse()s whatever arrived is not. text/plain is a simple type. OWASP names this under Disallowing simple content types.
method-override 3.0.0 is the other footgun. Its docs say mount it before any module that reads req.method. Default options.methods is POST only. Passing null lets every method be rewritten. A top-level GET then looks like POST to every later check, and Lax already attached the cookie.
// BAD: rewrite any method, including top-level GET
// app.use(methodOverride("_method", { methods: null }))
// FIX: method-override 3.0.0 default. Only POST is rewritten.
const methodOverride = require("method-override")
app.use(methodOverride("_method"))
On a JSON route, require both an allowed Sec-Fetch-Site and a real application/json Content-Type. A custom header such as X-Requested-With also forces a preflight. CORS must list exact origins you control. A wildcard or a subdomain regex hands the custom header to whoever takes a dangling host.
Gitpod OAuth cookie toss, and why SameSite never saw it
The NVD page for CVE-2024-21583 and the Snyk Labs writeup. The cookie name was the whole game. The session cookie _gitpod_io_jwt2_ had no Host prefix. A host under gitpod.io could set that cookie for the control plane. SameSite never fired, because the writer and the target were the same site.
Snyk Labs dated the public writeup 26 November 2024 and said they reported it on 26 June 2024. Gitpod shipped a fix on 1 July 2024 by putting the Host prefix on the cookie.
SameSite is a site boundary, not an origin lock. A workspace host, a docs host, a forgotten CNAME, a customer subdomain: all same-site with the app if they share the eTLD+1. Lax and Strict both send the cookie. The Host prefix refuses Domain, requires Path=/, and requires Secure. That is as close as a cookie gets to an origin.
res.cookie("__Host-session", sid, {
httpOnly: true,
secure: true,
sameSite: "lax",
path: "/",
});
Test your own app
You are not walking an exploit. You are proving your handler returned 403 when the browser said the request was cross-site.
- In your own app, open DevTools on a state-changing request you already make (save email, rotate a token, submit a settings form).
- Copy as cURL. Keep your Cookie header. Do not send that cookie anywhere else.
- Add or replace the Fetch Metadata header so it reads Sec-Fetch-Site: cross-site.
- Replay against your own origin. Expect 403. A 200 means the route ignored the header.
curl -sS -D - -o /dev/null -X POST "https://your-app.example/account/email" \
-H "Cookie: __Host-session=PASTE_FROM_YOUR_DEVTOOLS" \
-H "Sec-Fetch-Site: cross-site" \
-H "Content-Type: application/x-www-form-urlencoded" \
--data "email=you@your-app.example"
# Expect: HTTP/2 403
Run the same copy with the Sec-Fetch-Site line deleted. If you fail closed, that is also 403. A first-party integration that must pass should send Sec-Fetch-Site: none on purpose, not skip the check. Then grep your routers for app.get handlers that write to the database. Lax will send the cookie on those.
Defense checklist by app type
- Cookie session, server-rendered forms. Sec-Fetch-Site on POST/PUT/PATCH/DELETE. SameSite=Lax plus Host prefix on the session cookie. csrf-sync 4.2.1 if you want a token on the form for old browsers and login CSRF. Do not install csurf.
- Cookie session, SPA talking to the same origin. Same header check. csrf-csrf 4.0.3 if you still want a signed double-submit on X-CSRF-Token. Do not put the token in a cookie the script then echoes back as the only check.
- Bearer or Authorization header, no cookies. CSRF is the wrong ticket. Lock CORS. Do not invent a token to feel busy.
- Many subdomains, or user-controlled hosts on your eTLD+1. Deny same-site on mutations. Host-prefix every session cookie. Treat a sibling XSS as a CSRF plus cookie-toss problem. See the XSS guide.
- Login form (pre-auth). This is login CSRF. SameSite and Sec-Fetch-Site still help. A pre-session token is the leftover case the call on this page reserved. Destroy that pre-session after the real login.
What the internet thinks about CSRF
Two community discussions capture the recurring argument:
Hacker Newsminitech ยท 18 Oct 2025
“Sec-Fetch-Site is a replacement for CSRF tokens. The sole purpose of CSRF tokens is to prevent CSRF, and enforcing that all unsafe-method requests have a Sec-Fetch-Site: same-origin header serves exactly the same purpose.”
On Alex Edwards’s Go CSRF note. tankenmate’s dissent is the other half of that thread: he would never rely on Sec-Fetch-Site, because security that depends on a client-generated header is poor modelling, and he would keep a time-bounded HMAC cookie that still works when the browser is old. nchmy was ready to drop browsers that omit the header.
Stack OverflowMandoMando ยท Jan 2023
“Started using csrf-csrf on npm. Followed OWASP guidance on csrf.”
The question was an SSR Express app whose tutorials still named the archived package.
Security Stack ExchangeAnders ยท 2020
“Just the SameSite flag is not enough to protect your users from CSRF.”
The next answer on that thread is the GET line: Lax never covers a state-changing GET.
Questions we keep getting
Does a JSON API still need CSRF protection?
Yes, if the session cookie is included on the request. application/json is not a browser-enforced lock. Treat JSON mutating routes like form routes: require Sec-Fetch-Site same-origin or none. Treat same-site like cross-site unless you have listed every hostname on the eTLD+1.
When is a CSRF token still worth the code?
Login, password reset, and anything that runs before the session cookie exists. Those routes cannot lean on a cookie you have not issued yet. Tokens also cover browsers that never send Fetch Metadata, which is a shrinking set after March 2023.
Is method-override safe if I only allow POST?
The default methods list is ['POST']. That is the intended use. The footgun is methods: null, which lets a GET carry an override header or query and become a PUT. Leave the default. Do not open GET.



