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

Login CSRF

Login CSRF: an attacker tricks a victim into logging into an account the attacker controls. Once the victim is silently signed in, anything they do (uploads, saved searches, payment methods, OAuth flows) lands in the attacker’s account. Defense looks slightly different from classic CSRF because the user has no session yet.

Pre-session token + same-site + CAPTCHA

EXAMPLE
// SCENARIO — a login form. Defensive perspective.

// ─── VULNERABLE ────────────────────────────────────────────────

// Standard login form POSTs username + password to /login.
// No CSRF token on the form. Anyone can post to /login from anywhere.

app.post('/login', async (req, res) => {
    const user = await auth.verify(req.body.username, req.body.password);
    if (!user) return res.status(401).send('bad credentials');
    req.session.userId = user.id;
    res.redirect('/');
});

// An attacker page can submit this form with the attacker's own credentials.
// The browser sends them with the request. The victim is now signed in as the attacker.
// Anything they enter — payment methods, search history, OAuth-linked accounts — belongs to the attacker.

// ─── FIX 1 — Pre-session CSRF token ────────────────────────────

// Treat /login like any state-changing endpoint, but issue the token to the BROWSER
// before the user logs in. The token sits in a cookie + the form, double-submit style.

import crypto from 'node:crypto';

app.use((req, res, next) => {
    if (!req.cookies['pre-login-csrf']) {
        const token = crypto.randomBytes(32).toString('hex');
        res.cookie('pre-login-csrf', token, {
            httpOnly: false,    // form JS must read this
            secure:   true,
            sameSite: 'lax',
            maxAge:   1000 * 60 * 30,
        });
        res.locals.csrf = token;
    } else {
        res.locals.csrf = req.cookies['pre-login-csrf'];
    }
    next();
});

// Login form template
app.get('/login', (req, res) => {
    res.send(`
        <form method="POST" action="/login">
            <input type="hidden" name="csrf" value="${res.locals.csrf}">
            <input name="username">
            <input name="password" type="password">
            <button>Sign in</button>
        </form>
    `);
});

app.post('/login', async (req, res) => {
    const cookieToken = req.cookies['pre-login-csrf'];
    const formToken   = req.body.csrf;
    if (
        !cookieToken || !formToken ||
        !crypto.timingSafeEqual(Buffer.from(cookieToken), Buffer.from(formToken))
    ) {
        return res.status(403).send('login CSRF check failed');
    }
    // ... verify credentials, set session ...
});

// Why this works: the attacker page can't read the pre-login-csrf cookie (it's on YOUR origin)
// so they can't put the matching token in the form they're tricking the victim into submitting.

// ─── FIX 2 — SameSite=Lax on the pre-login cookie ──────────────

// SameSite=Lax means the cookie is sent on top-level navigations (which is what a form POST is),
// but NOT sent on cross-site subresource loads. Combined with the token check, the attacker
// page literally can't make the request the server accepts.

// SameSite=Strict is even tighter but breaks linking from external sites; Lax is the right balance.

// ─── FIX 3 — Custom request header for SPAs ────────────────────

// SPAs that login via fetch can require a non-simple header that the browser won't send
// cross-origin without a CORS preflight.

fetch('/login', {
    method: 'POST',
    headers: {
        'Content-Type': 'application/json',
        'X-Requested-With': 'XMLHttpRequest',     // forces preflight
    },
    body: JSON.stringify({ username, password }),
    credentials: 'include',
});

// Server
app.post('/login', (req, res, next) => {
    if (req.get('X-Requested-With') !== 'XMLHttpRequest') return res.sendStatus(403);
    next();
});

// Combined with strict CORS — never reflect Origin without an allowlist:
const ALLOWED = new Set(['https://app.example.com']);
app.use((req, res, next) => {
    const o = req.get('origin');
    if (o && ALLOWED.has(o)) {
        res.setHeader('Access-Control-Allow-Origin', o);
        res.setHeader('Access-Control-Allow-Credentials', 'true');
    }
    next();
});

// ─── FIX 4 — CAPTCHA on suspicious volume ──────────────────────

// CAPTCHA isn't a primary login-CSRF fix on its own, but it makes the attack pattern
// unworkable at scale. Trigger it on:
//   • Unusual referrer (no referrer from your own origin)
//   • Burst of logins from one network
//   • Login attempts that don't carry the pre-login token at all

app.post('/login', async (req, res) => {
    if (req.body.csrf !== req.cookies['pre-login-csrf']) {
        await captcha.require(res, '/login?reason=csrf');
        return;
    }
    /* ... */
});

// ─── FIX 5 — Bind the session to fresh device fingerprint ──────

// After login, refuse to act on /account/email-change without re-auth (password again).
// If a victim does end up logged in as the attacker briefly, re-auth on sensitive flows
// blunts the impact.

app.post('/account/email-change', requireRecentAuth({ maxAgeSeconds: 300 }), async (req, res) => {
    /* ... */
});

// ─── REGRESSION TESTS ──────────────────────────────────────────

import request from 'supertest';

test('login rejects request without pre-login CSRF token', async () => {
    const res = await request(app)
        .post('/login')
        .send({ username: 'bob', password: 'pw' });
    expect(res.status).toBe(403);
});

test('login rejects mismatched token', async () => {
    const formRes = await request(app).get('/login');
    const cookie = formRes.headers['set-cookie'][0];
    const res = await request(app)
        .post('/login')
        .set('Cookie', cookie)
        .send({ username: 'bob', password: 'pw', csrf: 'mismatched' });
    expect(res.status).toBe(403);
});

// ─── CHECKLIST ─────────────────────────────────────────────────
// 1. Pre-session CSRF token issued before any login attempt
// 2. SameSite=Lax (or Strict) on the pre-session and session cookies
// 3. JSON login endpoints require a custom header → CORS preflight required
// 4. Strict CORS allowlist; never echo Origin
// 5. Sensitive post-login flows require recent re-authentication
// 6. CAPTCHA on missing-token or burst patterns
// 7. Regression tests for missing-token + mismatched-token paths

Why it matters

Treat /login like any other state-changing endpoint, even though the user has no session yet: issue a pre-login CSRF token in a SameSite=Lax cookie, validate it on the POST, and require a custom header or JSON content-type so cross-origin forms can’t silently sign your users in as someone else.

Tip: Tweak the snippet with Try it Yourself », then sit the quiz at the bottom of the page.

Example

Example
// Attacker logs the victim INTO the attacker's account, so later actions
// (saved card, purchase) land in the attacker's account.
// Defence: protect /login with CSRF too. Pre-issued tokens or double-submit.
Try it Yourself »

Discussion

Loading…