CORS & Preflight
A CORS preflight is a browser-initiated OPTIONS request that asks “am I allowed to call this with these headers / methods?” Because simple requests skip preflight, the headers a request can carry without one matter for CSRF defence.
Force preflight, lock down CORS, pair with SameSite
EXAMPLE
// 1) Simple requests skip preflight — and that's the CSRF risk surface
// A <form> can POST text/plain or application/x-www-form-urlencoded WITHOUT
// preflight, so the browser sends cookies along. JSON content-type triggers
// preflight and is therefore CSRF-resistant on its own — IF the server enforces.
// 2) Server: REQUIRE a custom content-type or header — forces preflight
// Express middleware example
app.use((req, res, next) => {
if (req.method !== 'GET' && req.method !== 'HEAD') {
// Only accept JSON for mutating requests
const ct = req.headers['content-type'] ?? '';
if (!ct.includes('application/json')) {
return res.status(415).end(); // Unsupported Media Type
}
}
next();
});
// 3) Or require a custom header — non-simple, triggers preflight
app.use((req, res, next) => {
if (req.method !== 'GET' && !req.headers['x-csrf']) {
return res.status(403).end();
}
next();
});
// 4) CORS config — minimal, strict
import cors from 'cors';
app.use(cors({
origin: (origin, cb) => cb(null, ['https://app.example.com'].includes(origin ?? '')),
methods: ['GET', 'POST', 'PUT', 'DELETE'],
allowedHeaders: ['Content-Type', 'Authorization', 'X-CSRF'],
credentials: true, // allow cookies/Auth headers
maxAge: 600,
}));
// NEVER set Access-Control-Allow-Origin: * AND Allow-Credentials: true.
// Browsers will refuse, but it's also a misconfiguration to repeat anywhere else.
// 5) Cookies — pair with SameSite, the modern CSRF default
app.use(session({
cookie: {
sameSite: 'lax', // 'strict' for stricter; 'none' requires Secure + cross-site reason
secure: true,
httpOnly: true,
},
}));
// 6) Verifying preflight on the server — log unexpected OPTIONS
app.options('*', (req, res) => {
const origin = req.headers.origin;
if (!ALLOWED_ORIGINS.has(origin)) {
log.warn('CORS pre-flight from unexpected origin', origin, req.headers);
return res.status(403).end();
}
res.set({
'Access-Control-Allow-Origin': origin,
'Access-Control-Allow-Credentials': 'true',
'Access-Control-Allow-Methods': 'GET,POST,PUT,DELETE',
'Access-Control-Allow-Headers': 'Content-Type,Authorization,X-CSRF',
'Access-Control-Max-Age': '600',
});
res.status(204).end();
});
// 7) Defence-in-depth: CSRF token + SameSite + JSON content-type requirement
// Each layer covers a gap. SameSite=Lax catches most. Preflight blocks the rest.
// Token + double-submit catches the “subdomain XSS that bypasses SameSite” case.
Why it matters
A JSON-only API with strict CORS and SameSite=Lax cookies is CSRF-safe by construction — the browser won’t send cookies on a cross-site state-changing request. Add a CSRF token as belt-and-braces for legacy form POSTs.
Tip: Tweak the snippet with Try it Yourself », then sit the quiz at the bottom of the page.
Example
Example
// CORS preflight (OPTIONS) is your friend. // A request that needs a custom header is preflighted; attacker pages // can't trigger a preflight to YOUR origin without your CORS consent.Try it Yourself »
Exercise
HTTP method browsers use for CORS preflight.
method = '
'
Seven letters uppercase.
Discussion
Loading…