Express has no built-in CSRF protection, so add one middleware before every route: let POST, PUT, PATCH and DELETE through only when Sec-Fetch-Site is same-origin (or none), fall back to an exact Origin match when the browser sends no Sec-Fetch-Site, and refuse the rest with 403. Change state only in app.post() handlers, never on GET.
- Find every route that changes state, including app.all(), app.use() and router.route() handlers that also answer GET, and links that perform an action when followed.
- Register the middleware from this card with app.use() before any route, the sign-in route included, since login forms can be forged too.
- Configure the site's origin (here https://portal.example.test) and compare Origin with it exactly, rather than deriving it from the Host or X-Forwarded-Host header.
- Move every state change to app.post() (or put, patch, delete); a GET must never change data.
- Set the session cookie to httpOnly: true, secure: true and sameSite: 'lax' as defence in depth.
- If you must support browsers that send neither Sec-Fetch-Site nor Origin, add a synchronizer token as well (for example csrf-sync with express-session) instead of letting those requests through.
- Add regression tests that send the victim's cookie with the headers a browser sends for a cross-site form, a sibling subdomain, a no-referrer page and a cross-site link.
The three implementations share one API and one test suite; only the code below changes. The tests load the one that IMPL names.
The vulnerable code
export function emailRoutes(app, accounts) {
// app.all() answers every method: the form POSTs here, and the link in the
// "confirm your new address" email sends GET /account/email?email=...
app.all('/account/email', (req, res) => {
const account = accounts.get(req.session.user);
if (!account) return res.sendStatus(401);
const email = req.body?.email ?? req.query.email;
if (typeof email !== 'string' || !/^[^@\s]+@[^@\s]+$/.test(email)) {
return res.status(400).type('text').send('Enter a valid email address');
}
account.email = email;
res.redirect(303, '/account');
});
}With IMPL=vulnerable, 7 of 12 tests fail (7 exploit tests).
The fix
const SAFE_METHODS = new Set(['GET', 'HEAD', 'OPTIONS']);
export function protect(app, { trustedOrigin }) {
app.use((req, res, next) => {
if (SAFE_METHODS.has(req.method)) return next();
switch (req.get('Sec-Fetch-Site')) {
case 'same-origin':
case 'none': // the user typed the URL or opened a bookmark
return next();
case 'same-site': // a sibling subdomain is another origin
case 'cross-site':
return res.status(403).type('text').send('Cross-origin request refused');
default:
// No Sec-Fetch-Site: an older browser, or a site served over plain HTTP. Browsers
// have long sent Origin on POST requests; a missing Origin or "null" proves nothing.
if (req.get('Origin') === trustedOrigin) return next();
return res.status(403).type('text').send('Cross-origin request refused');
}
});
}With IMPL=fixed, 12 of 12 tests pass.
The fix: change state only on POST
export function emailRoutes(app, accounts) {
// Only POST changes the email; GET /account/email is not routed at all.
app.post('/account/email', (req, res) => {
const account = accounts.get(req.session.user);
if (!account) return res.sendStatus(401);
const email = req.body?.email;
if (typeof email !== 'string' || !/^[^@\s]+@[^@\s]+$/.test(email)) {
return res.status(400).type('text').send('Enter a valid email address');
}
account.email = email;
res.redirect(303, '/account');
});
}With IMPL=fixed, 12 of 12 tests pass.
A fix that looks safe but is not
const SAFE_METHODS = new Set(['GET', 'HEAD', 'OPTIONS']);
export function protect(app, { trustedOrigin }) {
const host = new URL(trustedOrigin).host; // "portal.example.test"
app.use((req, res, next) => {
const referer = req.get('Referer');
// Some browsers and privacy tools strip the Referer, so allow requests without one.
if (SAFE_METHODS.has(req.method) || !referer || referer.includes(host)) return next();
return res.status(403).type('text').send('Cross-site request refused');
});
}With IMPL=wrong, 3 of 12 tests fail (3 exploit tests).
The exploit tests send the attack and check that it fails; functional tests next to them keep ordinary requests working, so a fix that rejects everything fails too.
describe(`exploit (IMPL=${implName})`, () => {
// Each forged request carries Alice's session cookie. Browsers withhold a SameSite=Lax
// cookie from cross-site POSTs, but not from sibling subdomains or top-level GET
// navigations, and older browsers ignore SameSite, so the server must refuse anyway.
const forge = (browser, headers, fields = {}) =>
browser.send('POST', '/account/email', { headers, form: { ...fields, email: 'mallory@evil.example' } });
test('a cross-site form post without a token is refused', async () => {
const alice = await signIn('alice');
assertRefused(await forge(alice, formFrom(EVIL)));
});
test('a foreign Origin is refused when the browser sends no Fetch Metadata', async () => {
const alice = await signIn('alice');
assertRefused(await forge(alice, { Origin: EVIL, Referer: `${EVIL}/` }));
});
test('a sibling subdomain is refused although SameSite cookies reach it', async () => {
const alice = await signIn('alice');
assertRefused(await forge(alice, formFrom(SIBLING, 'same-site')));
});
test("a token from the attacker's own session is refused", async () => {
const bob = await signIn('bob');
const bobsFields = hiddenFields(await accountPage(bob));
const alice = await signIn('alice');
assertRefused(await forge(alice, formFrom(EVIL), bobsFields));
});
test('a Referer that merely contains the host name is refused', async () => {
const alice = await signIn('alice');
assertRefused(await forge(alice, formFrom(LOOKALIKE)));
});
test('a page that suppresses the Referer is refused', async () => {
// <meta name="referrer" content="no-referrer"> on the attacking page: no Referer, Origin: null.
const alice = await signIn('alice');
assertRefused(await forge(alice, { Origin: 'null', 'Sec-Fetch-Site': 'cross-site', 'Sec-Fetch-Mode': 'navigate', 'Sec-Fetch-Dest': 'document' }));
});
test('a cross-site GET navigation cannot change the email', async () => {
// Lax cookies are sent on top-level GET navigations, so only the method protects this.
const alice = await signIn('alice');
const res = await alice.send('GET', '/account/email?email=mallory%40evil.example', { headers: linkFrom(EVIL) });
assert.equal(emailOf('alice'), 'alice@example.test', `a GET changed Alice's email (HTTP ${res.status})`);
assert.ok(res.status >= 400, `expected an error status, got ${res.status}`);
});
});- csurf is deprecated and archived, and an Express maintainer has advised against it and its forks (expressjs/express discussion #5491). csrf-csrf and csrf-sync are community packages; their last releases were in May 2025.
- Browsers send Sec-Fetch-Site only to potentially trustworthy URLs (HTTPS, or localhost). Over plain HTTP the middleware falls back to Origin, so serve the site over HTTPS.
- same-site is not same-origin: a page on a sibling subdomain gets Sec-Fetch-Site: same-site and is sent SameSite cookies. Treat it as another origin unless you control every subdomain.
- Origin: null comes from sandboxed iframes, cross-origin redirects and form posts from pages with Referrer-Policy: no-referrer. Never allowlist the string null.
- A missing Origin is not evidence of a same-origin request. Go's http.CrossOriginProtection and ASP.NET Core 11's automatic CSRF protection let requests with neither header through as non-browser traffic; this card refuses them, as the OWASP cheat sheet now advises unless another defence such as a token succeeds.
- Behind a reverse proxy, compare Origin with the configured origin rather than req.get('host'); trust X-Forwarded-Host only when app.set('trust proxy') is limited to your proxies.
- Routes that also accept a bearer token must not fall back to the session cookie when the token is missing, or the route is CSRF-able again.
- CORS does not stop CSRF: a cross-site form post needs no preflight, and an unreadable response does not undo the change. Keep credentialed CORS lists explicit; cors({ origin: true, credentials: true }) reflects every origin and lets any site read signed-in responses.
- Run the tests in all three modes: IMPL=fixed must pass; IMPL=vulnerable and IMPL=wrong must fail (node ../../run-all.mjs csrf/node-express does this for you).
- Against your own app, replay a forged post with a signed-in user's cookie, for example curl -i -b 'sid=...' -H 'Origin: https://evil.example' -H 'Sec-Fetch-Site: cross-site' --data 'email=mallory@evil.example' https://your.app/account/email, and expect 403 with no change; repeat with Origin: null and as a GET.
- Scanners do not see this defence: the Semgrep rule above found nothing on an ES module app and flags any CommonJS app without csurf, and CodeQL recognises only token-based protection, so the regression tests are the control.
What each rule reported when it was run on this card's code, or what its source shows it covers where it was not run. A rule that exists for the weakness does not always catch it.
| Tool | Rule | What it found here |
|---|---|---|
| Semgrep | javascript.express.security.audit.express-check-csurf-middleware-usage.express-check-csurf-middleware-usage | No findings on this card, for any implementation: the rule only matches apps that load Express with require('express'), and this card uses ES module imports. On CommonJS copies it fired on an app without protection and also on this card's fixed middleware: it recognises only the csurf and csrf packages, so it cannot tell the fix from the bug.Checked: Semgrep 1.180.0 (CE) |
| CodeQL | js/missing-token-validation | From the query source: it reports handlers registered for a specific unsafe method (app.post, app.put and so on, not app.all) that read req.session or req.cookies behind cookie middleware such as express-session, and it accepts as protection only the CSRF libraries it knows (csurf, tiny-csrf, lusca, Fastify's csrfProtection) or handlers that write or compare a session property or cookie named like csrf. It has no model of a Sec-Fetch-Site or Origin check. Whether it links this card's routes to express-session through the dynamic import of impl/*.js was not tested.Checked: github/codeql main at b5b8165; not executed |
| Component | Version |
|---|---|
| Language | JavaScript (ES modules) |
| Runtime | Node.js 26.11.1 |
| Framework | Express 5.3.0 |
| express | 5.3.0 |
| express-session | 1.19.0 |
Download the project, install its dependencies once (this step needs the network), then run the tests against each implementation from the csrf/node-expressfolder.
npm ci# Expected: every test passes
IMPL=fixed npm test --silent
# Expected: an exploit test fails
IMPL=vulnerable npm test --silent
# Expected: a test fails
IMPL=wrong npm test --silentEach run writes a JUnit report to reports/junit.xml. From the top folder of the download, node run-all.mjs csrf/node-express runs all three modes and checks the expected results.
Verified
- Machine
- macOS 27.0 (Darwin 27.0.0, arm64)
- Toolchain
- node 26.11.1
- npm 11.20.0
- express 5.3.0
- expressSession 1.19.0
- Matrix
- fixed: 12 of 12 tests pass; vulnerable: 7 of 12 tests fail (7 exploit tests); wrong: 3 of 12 tests fail (3 exploit tests)
- Test results
- fixed 12/12 pass; vulnerable 7/12 fail (all 7 exploit tests); wrong 3/12 fail (3 exploit tests), offline after npm ci
- Dependency freshness
- express 5.3.0 was published on 2026-10-09; it is taken at one day old because it is a security release (qs and proxy-addr advisories). express-session 1.19.0 dates from 2026-01-22. npm audit found 0 vulnerabilities and npm audit signatures verified all 73 packages. Rerun on Node.js 26.11.1, above the 26.5.1 security floor in engines.
Versions and results hold for the toolchain above. The card's tests are the check: run them with your own versions before relying on the fix.
References
- OWASP Cross-Site Request Forgery Prevention Cheat Sheet (Fetch Metadata headers, verifying origin with standard headers)
- W3C Fetch Metadata Request Headers
- MDN: Sec-Fetch-Site
- MDN: Origin (when browsers send it, and when it is null)
- MDN: Referrer-Policy (effect on the Origin header)
- Go net/http: CrossOriginProtection (the same Sec-Fetch-Site and Origin approach, added in Go 1.25)
- Filippo Valsorda: Cross-Site Request Forgery
- expressjs/express discussion #5491: CSRF (and csurf) in 2024
- express-session (cookie options)
- Express behind proxies (trust proxy)
Attack patterns: