A cross-site request forgery needs only a form on a page the victim visits: the browser attaches your cookies to the POST, and your server sees a perfectly authenticated request. The defense is a secret the attacker's page cannot read. csurf 2,305 , the standard answer for a decade, is archived and unmaintained: do not install it. Use csrf-csrf 196 (ISC, Psifi-Solutions/csrf-csrf (https://github.com/Psifi-Solutions/csrf-csrf 196 ), 4.0.3) for the stateless double-submit cookie pattern, or csrf-sync for the synchronizer-token pattern. Double-submit sends a random value in an HttpOnly cookie and embeds an HMAC of that value plus the session identifier in the page. The forged request carries the cookie but cannot carry the HMAC: reading your HTML cross-origin is what the same-origin policy forbids.
const { doubleCsrfProtection, generateCsrfToken } = doubleCsrf({
getSecret: () => process.env.CSRF_SECRET,
getSessionIdentifier: (req) => req.session.id, // binds the token to one session
cookieName: 'x-csrf-token', // '__Host-x-csrf-token' over HTTPS
cookieOptions: { sameSite: 'lax', secure: true, httpOnly: true, path: '/' },
getCsrfTokenFromRequest: (req) => req.body?._csrf || req.headers['x-csrf-token']
});
app.get('/transfer', (req, res) =>
res.render('transfer', { token: generateCsrfToken(req, res) })); // also sets the cookie
app.post('/transfer', doubleCsrfProtection, (req, res) =>
res.json({ moved: req.body.amount, to: req.body.to }));
// a failed check throws err.code === 'EBADCSRFTOKEN'; map it to 403 in the error handler[1] real form -> 200 {"moved":"25","to":"alice"}
[2] no token -> 403 {"error":"invalid csrf token"}
[3] fake token -> 403 {"error":"invalid csrf token"}
[4] other session -> 403 {"error":"invalid csrf token"}Case 4 separates a correct implementation from a plausible-looking one. That request used a genuine token, freshly minted by the same server, with the victim's cookies - and was still rejected, because getSessionIdentifier mixed the victim's session ID into the HMAC. A library that only compares cookie with form value passes cases 2 and 3 and fails case 4.
GET, HEAD and OPTIONS are exempt by default, which is correct only if those routes change nothing: if you have a GET /account/delete, fix the route, not the exemption list. A single-page front end instead fetches the token from GET /api/csrf and returns it in a header, which is why the reader above checks both places.