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…