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

MFA & TOTP

Multi-factor authentication mixes something the user knows (password) with something they have (TOTP, security key) or something they are (biometric). Done right, MFA neutralises most credential-theft attacks; done wrong, recovery flows and SMS fallbacks reopen the door.

TOTP, WebAuthn, recovery, deployment

EXAMPLE
// 1) TOTP — RFC 6238 time-based codes (Google Authenticator, Authy, 1Password)
// npm install otplib qrcode
import { authenticator } from 'otplib';
import qrcode from 'qrcode';

// Setup
const secret = authenticator.generateSecret();  // 32-char base32
await db.user.update({ where: { id: userId }, data: { totpSecret: secret, totpVerified: false } });

const otpauth = authenticator.keyuri('mara@example.com', 'MyApp', secret);
const qrSvg   = await qrcode.toString(otpauth, { type: 'svg' });
// Render QR code in the UI; user scans with their authenticator app.

// Verify code
const valid = authenticator.verify({ token: '123456', secret });
authenticator.options = { window: 1 };  // tolerate ±30s clock skew

// 2) Confirm + activate MFA — require correct code BEFORE flipping the flag
async function confirmMfa(userId, code) {
    const user = await db.user.findUnique({ where: { id: userId } });
    if (!authenticator.verify({ token: code, secret: user.totpSecret })) {
        throw new Error('invalid code');
    }
    await db.user.update({ where: { id: userId }, data: { totpVerified: true } });
}

// 3) WebAuthn / passkeys — phishing-resistant (recommended for new apps)
// npm install @simplewebauthn/server @simplewebauthn/browser
import {
    generateRegistrationOptions, verifyRegistrationResponse,
    generateAuthenticationOptions, verifyAuthenticationResponse,
} from '@simplewebauthn/server';

const rpID    = 'app.example.com';
const rpName  = 'My App';
const origin  = 'https://app.example.com';

// Register a new key
app.post('/webauthn/register/options', async (req, res) => {
    const user = req.session.user;
    const opts = await generateRegistrationOptions({
        rpName,
        rpID,
        userID: Buffer.from(String(user.id)),
        userName: user.email,
        attestationType: 'none',
        excludeCredentials: (await db.passkey.findMany({ where: { userId: user.id } })).map((p) => ({
            id: Buffer.from(p.credentialId, 'base64url'),
            type: 'public-key',
            transports: p.transports,
        })),
        authenticatorSelection: {
            residentKey: 'preferred',
            userVerification: 'preferred',
        },
    });
    await redis.set(`webauthn:reg:${user.id}`, opts.challenge, 'EX', 60);
    res.json(opts);
});

app.post('/webauthn/register/verify', async (req, res) => {
    const user = req.session.user;
    const expected = await redis.get(`webauthn:reg:${user.id}`);
    const v = await verifyRegistrationResponse({
        response: req.body,
        expectedChallenge: expected,
        expectedOrigin: origin,
        expectedRPID: rpID,
    });
    if (!v.verified) return res.sendStatus(403);
    await db.passkey.create({ data: {
        userId: user.id,
        credentialId: Buffer.from(v.registrationInfo.credentialID).toString('base64url'),
        publicKey: Buffer.from(v.registrationInfo.credentialPublicKey).toString('base64url'),
        counter: v.registrationInfo.counter,
        transports: req.body.response.transports ?? [],
    } });
    res.json({ ok: true });
});

// Authenticate
app.post('/webauthn/login/options', async (req, res) => {
    const email = req.body.email;
    const user = await db.user.findUnique({ where: { email } });
    const passkeys = await db.passkey.findMany({ where: { userId: user.id } });
    const opts = await generateAuthenticationOptions({
        rpID,
        userVerification: 'preferred',
        allowCredentials: passkeys.map((p) => ({ id: Buffer.from(p.credentialId, 'base64url'), type: 'public-key' })),
    });
    await redis.set(`webauthn:auth:${user.id}`, opts.challenge, 'EX', 60);
    res.json(opts);
});

app.post('/webauthn/login/verify', async (req, res) => {
    const user = await findByCredId(Buffer.from(req.body.id, 'base64url'));
    if (!user) return res.sendStatus(404);
    const passkey = await db.passkey.findUnique({ where: { credentialId: req.body.id } });
    const expected = await redis.get(`webauthn:auth:${user.id}`);
    const v = await verifyAuthenticationResponse({
        response: req.body,
        expectedChallenge: expected,
        expectedOrigin: origin,
        expectedRPID: rpID,
        authenticator: {
            credentialPublicKey: Buffer.from(passkey.publicKey, 'base64url'),
            credentialID: Buffer.from(passkey.credentialId, 'base64url'),
            counter: passkey.counter,
        },
    });
    if (!v.verified) return res.sendStatus(403);
    await db.passkey.update({ where: { id: passkey.id }, data: { counter: v.authenticationInfo.newCounter } });
    req.session.userId = user.id;
    res.json({ ok: true });
});

// 4) Recovery codes — for lost device scenarios
import crypto from 'node:crypto';
import bcrypt from 'bcrypt';

async function generateRecoveryCodes(userId, count = 10) {
    const codes = Array.from({ length: count }, () => crypto.randomBytes(5).toString('hex'));
    const hashed = await Promise.all(codes.map((c) => bcrypt.hash(c, 10)));
    await db.recoveryCode.createMany({ data: hashed.map((h) => ({ userId, codeHash: h, usedAt: null })) });
    return codes;                              // show ONCE; user must save
}

async function consumeRecoveryCode(userId, code) {
    const candidates = await db.recoveryCode.findMany({ where: { userId, usedAt: null } });
    for (const c of candidates) {
        if (await bcrypt.compare(code, c.codeHash)) {
            await db.recoveryCode.update({ where: { id: c.id }, data: { usedAt: new Date() } });
            return true;
        }
    }
    return false;
}

// 5) Step-up authentication — require MFA only when sensitive
function requireRecentMfa({ maxAgeSeconds }) {
    return (req, res, next) => {
        const last = req.session.mfaAt ?? 0;
        if (Date.now() - last > maxAgeSeconds * 1000) {
            req.session.mfaReturnTo = req.originalUrl;
            return res.redirect('/auth/mfa');
        }
        next();
    };
}

app.post('/account/email-change', requireRecentMfa({ maxAgeSeconds: 300 }), changeEmail);
app.post('/account/disable-mfa', requireRecentMfa({ maxAgeSeconds: 60 }), disableMfa);

// 6) Avoid SMS — it's the WEAKEST MFA factor
// • SIM swap attacks bypass it
// • If you must offer SMS, log + alert on SIM swap signals (carrier change, recent port-out)
// • Prefer TOTP + WebAuthn; SMS is a fallback, not a default

// 7) Backup codes vs second device
// • Backup codes — show ONCE on enrolment + after each consumption
// • Recommend at least TWO authenticators (e.g. phone + hardware key) — most credential takeovers come from
//   loss/breakage of the only second factor

// 8) UX patterns
// • Onboarding: encourage MFA in a 'security checkup' flow; don't require on first signup
// • Remember device: cookie that bypasses MFA for 30 days; rotate on logout
// • Account recovery: support-driven recovery is a backdoor; prefer self-service with verified secondary factor
// • Communicate clearly when MFA is disabled (email notification)

// 9) Compliance
// • PCI-DSS 4.0 requires MFA for any access to cardholder data systems
// • HIPAA-aligned organisations: MFA for remote and admin access
// • SOC 2: documented MFA controls + evidence

// 10) Audit + monitoring
// • Log: MFA enrolled, verified, disabled, code attempts
// • Alert: 5+ failed code attempts in 5 min
// • Track 'MFA coverage' as a metric (% of active users with MFA)
// • Annual review: remove inactive passkeys, expired enrollments

// 11) Common bugs
// • Storing TOTP secret in plain text → DB compromise = factor stolen; encrypt with KMS
// • Not checking time window → users with skewed clocks get locked out; window: 1
// • Allowing same TOTP code twice → replay; store last-used counter or window
// • Recovery codes shown again on second view → must show once + invalidate on regenerate
// • No rate limiting on /verify → brute force the 6-digit code; lock after 5 attempts
// • SMS as default — switch to TOTP / WebAuthn ASAP
// • Disabling MFA only requires password → step-up; require existing MFA to disable
// • WebAuthn rpID typos → all keys silently fail; verify origin + rpID in tests

Why it matters

For new apps default to passkeys (WebAuthn) plus TOTP; treat SMS as a last-resort fallback. Hash recovery codes like passwords, require recent MFA for sensitive flows, and rate-limit code verification. Encourage users to register at least two factors so a lost phone doesn’t lock them out for good.

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

Example

Example
// TOTP (RFC 6238): 6-digit code, 30s window. otplib / pyotp / Google Authenticator.
// Rate-limit attempts; store the secret in a column encrypted with your KMS.
Try it Yourself »

Discussion

Loading…