Cross-site request forgery (CSRF)¶
When is it relevant¶
A new or changed state-changing entry point (POST, PUT, PATCH, DELETE, or a GET that changes state) that authenticates with credentials the browser sends automatically: cookies, HTTP basic auth or client certificates. It doesn't apply when the only credential is a header the page has to set itself, such as Authorization: Bearer.
Attack¶
A page on another site makes the victim's browser send the request, and the browser attaches their cookies. This works through an auto-submitting form, an image tag for GET, or fetch with mode: 'no-cors'. JSON endpoints are exposed too if they also accept form or text/plain bodies, or if CORS allows credentials from any origin.
Mitigation¶
- Set session cookies with
SameSite=LaxorStrict, plusSecureandHttpOnly. - On unsafe methods, reject cross-site requests. Use a CSRF token, or check
Sec-Fetch-SiteorOriginagainst your own origin. - Never change state on
GET. Accept onlyapplication/jsonbodies where possible. - Don't reflect arbitrary origins in
Access-Control-Allow-Origintogether with credentials.
Examples¶
Mitigation options
| Option | When it fits | Docs |
|---|---|---|
SameSite on the session cookie |
Always; the cheapest baseline | express-session |
Double-submit token middleware (csrf-csrf) |
Form apps, and anywhere you need SameSite=None |
csrf-csrf |
| Synchronizer token in the session | You already have a server-side session and want no extra dependency | express-session |
| Custom header + strict CORS | JSON APIs called by your own front end | OWASP CSRF |
csurf was the usual answer for years. It is deprecated and archived — do
not add it to new code, and flag it in review (expressjs/csurf).
Vulnerable — cookie session, no SameSite, state-changing POST
app.use(session({ secret: process.env.SESSION_SECRET, cookie: { httpOnly: true } }))
// cookie.sameSite is undefined -> the browser default varies by browser and version
app.post('/account/email', requireAuth, async (req, res) => {
await User.updateOne({ _id: req.user.id }, { email: req.body.email })
res.redirect('/account')
})
Any site can auto-submit a form to this endpoint and the browser attaches the session cookie.
Vulnerable — state change on GET
Vulnerable — token issued but never bound or compared
app.use((req, res, next) => {
res.locals.csrfToken = req.session.csrfToken ??= crypto.randomUUID()
next()
})
app.post('/account/email', requireAuth, async (req, res) => {
// the form renders the token, but nothing checks it on the way back in
await User.updateOne({ _id: req.user.id }, { email: req.body.email })
res.redirect('/account')
})
Mitigated — SameSite
app.use(session({
secret: process.env.SESSION_SECRET,
cookie: { httpOnly: true, secure: true, sameSite: 'lax' },
}))
lax still sends the cookie on top-level GET navigations, so it only protects
you if no GET route changes state. Use strict when you don't need inbound
links to arrive authenticated.
Mitigated — double-submit token middleware (csrf-csrf v4)
import { doubleCsrf } from 'csrf-csrf'
const { doubleCsrfProtection, generateCsrfToken, invalidCsrfTokenError } = doubleCsrf({
getSecret: () => process.env.CSRF_SECRET, // required
getSessionIdentifier: (req) => req.session.id, // required: binds token to session
cookieOptions: { sameSite: 'lax', secure: true, path: '/' },
// forms post the token in a field; the default reads the x-csrf-token header
getCsrfTokenFromRequest: (req) => req.body?._csrf ?? req.get('x-csrf-token'),
})
// order matters: session and cookies must be parsed before the CSRF middleware
app.use(session({ /* ... */ }))
app.use(cookieParser())
app.use(express.urlencoded({ extended: false }))
// protect every state-changing request app-wide (GET/HEAD/OPTIONS are skipped)
app.use(doubleCsrfProtection)
app.get('/account', requireAuth, (req, res) => {
res.render('account', { csrfToken: generateCsrfToken(req, res) })
})
app.post('/account/email', requireAuth, async (req, res) => {
await User.updateOne({ _id: req.user.id }, { email: req.body.email })
res.redirect('/account')
})
app.use((err, req, res, next) => {
if (err === invalidCsrfTokenError) return res.status(403).render('error', { message: 'Invalid CSRF token' })
next(err)
})
<form method="post" action="/account/email">
<input type="hidden" name="_csrf" value="<%= csrfToken %>">
<input type="email" name="email" required>
<button type="submit">Save</button>
</form>
getSessionIdentifier is what makes this a real defence: the token is an HMAC
over the session ID, so a token minted for the attacker's own session is
rejected when replayed against the victim's.
Docs: csrf-csrf · cookie-parser · express-session
Mitigated — synchronizer token middleware of your own
import crypto from 'node:crypto'
const IGNORED = new Set(['GET', 'HEAD', 'OPTIONS'])
export function csrfTokens(req, res, next) {
if (!req.session) return next(new Error('csrfTokens requires a session'))
req.session.csrfSecret ??= crypto.randomBytes(32).toString('base64url')
res.locals.csrfToken = req.session.csrfSecret // available to every view
next()
}
export function requireCsrfToken(req, res, next) {
if (IGNORED.has(req.method)) return next()
const expected = Buffer.from(req.session?.csrfSecret ?? '', 'utf8')
const supplied = Buffer.from(req.body?._csrf ?? req.get('x-csrf-token') ?? '', 'utf8')
if (expected.length === 0 || expected.length !== supplied.length ||
!crypto.timingSafeEqual(expected, supplied)) {
return res.status(403).render('error', { message: 'Invalid CSRF token' })
}
next()
}
app.use(csrfTokens)
app.use(requireCsrfToken)
The secret lives in the session, so it is per-user and dies with the session.
timingSafeEqual needs equal-length buffers, which is why the length is checked
first rather than inside the comparison.
Docs: crypto.timingSafeEqual · express-session
Mitigated — custom header for a JSON API
app.use(cors({ origin: 'https://app.example.com', credentials: true }))
app.use((req, res, next) => {
if (['GET', 'HEAD', 'OPTIONS'].includes(req.method)) return next()
if (req.get('X-Requested-With') !== 'XMLHttpRequest') return res.sendStatus(403)
next()
})
This works because a cross-origin request carrying a custom header needs a preflight, which your CORS policy refuses. It fails if the endpoint also accepts simple content types.
Docs: cors
Bypasses to check
- Token generated and rendered but never validated, validated only on some
routes, or compared with
==instead of a constant-time comparison. - Token not bound to the session: a global or per-process value, or
getSessionIdentifierreturning a constant, lets an attacker use a token minted from their own session. - The protection middleware mounted after the routes it should cover, or
before
cookieParser/express.urlencoded, so the token is never parsed. ignoredMethodswidened, or a state-changingGET— both leave a write path with no token.- Body parsed as
application/jsonin the middleware but the route also acceptstext/plainorapplication/x-www-form-urlencoded, which need no preflight. - Method override:
?_method=POSTorX-HTTP-Method-Overrideturning a GET into a write. sameSite: 'none'without a token; or the__Host-cookie prefix used with adomainset orpathother than/, so the cookie is silently rejected and the check falls back to something weaker.- CORS that reflects
Originwithcredentials: true, or an allowlist matched withstartsWith/includessohttps://app.example.com.evil.compasses. - Login CSRF: the login route itself has no token, letting an attacker log a victim into the attacker's account.
- Token rotated on every render but the old one still accepted indefinitely, or not rotated on privilege change (login, re-authentication).
What to grep for
sameSite,credentials: true,cors(,origin:csurf,doubleCsrf,doubleCsrfProtection,generateCsrfToken,getSessionIdentifier,_csrf,X-Requested-WithtimingSafeEqual,===next to a token comparisonapp.get(on paths containingdelete,remove,update,approve