CSRF Protection
Cookie-based auth needs CSRF defense; SameSite cookies and the double-submit token are your two layers — and header-token APIs mostly don't.
Cross-Site Request Forgery tricks a logged-in user's browser into making a state-changing request to your site. It only works because browsers automatically attach your cookies to any request to your domain, even one triggered by an attacker's page. Understand that and the defenses follow.
Who needs to worry
CSRF targets cookie-based auth. A pure API that authenticates with an Authorization: Bearer header is largely immune, because the attacker's page cannot read your token or set that header cross-origin. Sessions and auth cookies, however, need protection.
Two layers, use both
- SameSite cookies — set
sameSite: 'lax'(or'strict'). The browser then withholds the cookie on most cross-site requests. This alone stops the majority of CSRF. - Anti-CSRF token — the double-submit pattern: issue a random token in a readable cookie and require it echoed back in a header. An attacker's cross-origin page cannot read the cookie, so it cannot forge the header.
Example
import express from 'express';
import cookieParser from 'cookie-parser';
import { doubleCsrf } from 'csrf-csrf';
const app = express();
app.use(cookieParser(process.env.COOKIE_SECRET));
app.use(express.json());
const { generateToken, doubleCsrfProtection } = doubleCsrf({
getSecret: () => process.env.CSRF_SECRET,
cookieName: '__Host-csrf',
cookieOptions: { sameSite: 'lax', secure: true, path: '/' },
// Only guard state-changing methods; GET/HEAD/OPTIONS are safe.
ignoredMethods: ['GET', 'HEAD', 'OPTIONS'],
});
// The SPA fetches a token first, then echoes it in the x-csrf-token header.
app.get('/csrf-token', (req, res) => {
res.json({ token: generateToken(req, res) });
});
// Protection verifies cookie-vs-header match on mutating requests.
app.post('/api/transfer', doubleCsrfProtection, (req, res) => {
res.json({ ok: true, moved: req.body.amount });
});When to use it
- A server-rendered Express app uses csurf to generate a CSRF token embedded in every HTML form, rejecting state-changing requests without a valid token.
- A developer disables CSRF protection for stateless JWT-authenticated API routes while keeping it active for session-based browser routes.
- A SPA reads a CSRF token from a cookie using the double-submit cookie pattern and sends it back in a custom X-CSRF-Token header.
More examples
CSRF token in HTML form
Registers csurf with cookie storage, generates a token for the GET form route, and validates it automatically on POST.
const csrf = require('csurf');
const csrfProtect = csrf({ cookie: true });
app.use(require('cookie-parser')());
app.get('/form', csrfProtect, (req, res) => {
// req.csrfToken() generates and stores the token
res.render('form', { csrfToken: req.csrfToken() });
});
app.post('/submit', csrfProtect, (req, res) => {
// csurf validates _csrf field from req.body automatically
res.json({ ok: true });
});CSRF error handler
Catches the EBADCSRFTOKEN error code thrown by csurf and returns a 403 Forbidden response with a clear message.
app.use((err, req, res, next) => {
if (err.code === 'EBADCSRFTOKEN') {
return res.status(403).json({ error: 'Invalid or missing CSRF token' });
}
next(err);
});Double-submit cookie for SPAs
Implements the double-submit cookie pattern: the SPA reads the csrf-token cookie and echoes it in an X-CSRF-Token header.
const crypto = require('crypto');
// Send token as a readable cookie (SPA reads and re-sends it as a header)
app.use((req, res, next) => {
if (!req.cookies['csrf-token']) {
res.cookie('csrf-token', crypto.randomBytes(32).toString('hex'), { sameSite: 'strict' });
}
next();
});
app.post('/api/action', (req, res) => {
if (req.cookies['csrf-token'] !== req.headers['x-csrf-token']) {
return res.status(403).json({ error: 'CSRF check failed' });
}
res.json({ ok: true });
});
Discussion