iwantcoding.com
🔥 Daily 👥 Rooms 🏆 Top Log in Sign up

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 = ' '

Discussion

Loading…