Multi-Factor Authentication (MFA): Complete Reference
How do I implement Multi-Factor Authentication?
TL;DR
- Bottom line: Implement TOTP (RFC 6238) as the default MFA method for broad compatibility, and add WebAuthn/FIDO2 for phishing-resistant authentication on modern platforms.
- Key tool/command:
otplib(Node.js) /pyotp(Python) for TOTP;@simplewebauthn/serverfor WebAuthn. - Watch out for: Storing TOTP secrets in plaintext -- always encrypt at rest with AES-256-GCM and rate-limit verification to 3-5 attempts/minute.
- Works with: Any web framework; TOTP works on all platforms; WebAuthn requires HTTPS and a modern browser (Chrome 67+, Firefox 60+, Safari 14+).
Constraints
- TOTP shared secrets must be encrypted at rest (AES-256-GCM or equivalent) -- never store in plaintext [src1]
- Rate-limit verification attempts to 3-5 per minute per user -- TOTP codes have only 10^6 combinations per 30-second window [src3]
- Backup/recovery codes are one-time-use -- hash them with bcrypt/argon2, never store plaintext [src5]
- SMS OTP must not be the sole second factor -- NIST SP 800-63-4 deprecated SMS as a standalone authenticator due to SIM-swap and SS7 attacks [src2]
- WebAuthn relying party ID (rpID) must exactly match the effective domain or a registrable suffix -- misconfiguration causes silent registration failure [src4]
- TOTP clock drift tolerance should be +/- 1 step (30 seconds) maximum -- wider windows significantly increase brute-force attack surface [src3]
Quick Reference
| MFA Method | Security Level | UX Friction | Phishing Resistant | Implementation Complexity | NIST AAL | Key Standard |
|---|---|---|---|---|---|---|
| TOTP (Authenticator App) | High | Low | No | Low | AAL2 | RFC 6238 |
| WebAuthn/FIDO2 (Passkeys) | Very High | Very Low | Yes | Medium | AAL2-AAL3 | W3C WebAuthn L2 |
| SMS OTP | Low | Medium | No | Low | Restricted (deprecated) | -- |
| Email OTP | Low | High | No | Very Low | Deprecated in SP 800-63-4 | -- |
| Push Notification | High | Very Low | Partial (fatigue attacks) | High | AAL2 | Proprietary |
| Hardware Security Key (U2F) | Very High | Low | Yes | Medium | AAL3 | FIDO U2F / CTAP2 |
| Backup Codes | N/A (recovery only) | Low | No | Very Low | N/A | -- |
Factor Categories
| Factor Type | Examples | Storage/Transmission |
|---|---|---|
| Knowledge (something you know) | Password, PIN, security question | Hashed (argon2id) |
| Possession (something you have) | Phone (TOTP app), security key, smart card | Challenge-response / HMAC |
| Inherence (something you are) | Fingerprint, face, iris | Biometric template (on-device) |
Decision Tree
START
+-- Need phishing resistance?
| +-- YES --> WebAuthn/FIDO2 (passkeys or hardware keys)
| +-- NO v
+-- Users have smartphones?
| +-- YES --> TOTP via authenticator app (Google Authenticator, Authy, 1Password)
| +-- NO v
+-- Enterprise with MDM?
| +-- YES --> Push notification (Duo, Okta Verify, Microsoft Authenticator)
| +-- NO v
+-- SMS/Email only as last resort?
| +-- YES --> SMS OTP (always pair with rate limiting + fraud detection)
| +-- NO v
+-- DEFAULT --> TOTP as primary + WebAuthn as optional upgrade path
Step-by-Step Guide
1. Generate and store TOTP secret
Create a cryptographically random 160-bit (20-byte) secret per user. Encrypt it before storing in your database. [src3]
// Node.js -- using otplib v12+
const { authenticator } = require('otplib');
const crypto = require('crypto');
// Generate a base32-encoded secret (160-bit)
const secret = authenticator.generateSecret(20);
// Encrypt before storing (AES-256-GCM)
function encryptSecret(plaintext, encryptionKey) {
const iv = crypto.randomBytes(12);
const cipher = crypto.createCipheriv('aes-256-gcm', encryptionKey, iv);
let encrypted = cipher.update(plaintext, 'utf8', 'hex');
encrypted += cipher.final('hex');
const tag = cipher.getAuthTag().toString('hex');
return `${iv.toString('hex')}:${tag}:${encrypted}`;
}
Verify: authenticator.generate(secret) returns a 6-digit string.
2. Generate QR code for authenticator app enrollment
Build an otpauth:// URI and render it as a QR code for the user to scan. [src3]
const QRCode = require('qrcode');
const otpauthUrl = authenticator.keyuri(
user.email, // account label
'YourAppName', // issuer
secret // base32 secret
);
const qrCodeDataUrl = await QRCode.toDataURL(otpauthUrl);
// Render qrCodeDataUrl in <img src="..."> for user to scan
Verify: Scan the QR code with Google Authenticator -- it shows the app name and generates 6-digit codes.
3. Verify TOTP code and enable MFA
Accept the user's first TOTP code to confirm enrollment before enabling MFA on their account. [src3]
const isValid = authenticator.check(userSubmittedToken, secret);
if (isValid) {
// Generate 10 backup codes
const backupCodes = Array.from({ length: 10 }, () =>
crypto.randomBytes(4).toString('hex')
);
// Hash each backup code before storing
const hashedCodes = await Promise.all(
backupCodes.map(code => bcrypt.hash(code, 12))
);
// Store hashedCodes, set mfa_enabled = true
// Show backupCodes to user ONCE (never again)
}
Verify: Submit a valid 6-digit code from authenticator -- returns isValid === true.
4. Enforce MFA on login flow
After password verification, require MFA verification before issuing a session. [src5]
app.post('/login', async (req, res) => {
const { email, password, mfaToken } = req.body;
const user = await verifyPassword(email, password);
if (!user) return res.status(401).json({ error: 'Invalid credentials' });
if (user.mfa_enabled) {
if (!mfaToken) {
return res.status(200).json({
mfa_required: true,
mfa_challenge_token: signTempToken(user.id, '5m')
});
}
// Rate-limit: 3 attempts per 60 seconds
const attempts = await getMfaAttempts(user.id, 60);
if (attempts >= 3) {
return res.status(429).json({ error: 'Too many attempts' });
}
await recordMfaAttempt(user.id);
const secret = decryptSecret(user.mfa_secret_encrypted, KEY);
if (!authenticator.check(mfaToken, secret)) {
return res.status(401).json({ error: 'Invalid MFA code' });
}
}
const session = await createSession(user.id, { mfa_verified: true });
res.json({ session_token: session.token });
});
Verify: Login without MFA token returns { mfa_required: true }. Valid TOTP completes login.
5. Implement WebAuthn registration (optional upgrade)
Add WebAuthn as a phishing-resistant second factor using SimpleWebAuthn. [src4] [src7]
const { generateRegistrationOptions, verifyRegistrationResponse } = require('@simplewebauthn/server');
app.get('/webauthn/register/options', async (req, res) => {
const user = req.authenticatedUser;
const existingDevices = await getUserAuthenticators(user.id);
const options = await generateRegistrationOptions({
rpName: 'YourAppName',
rpID: 'yourdomain.com',
userID: user.id,
userName: user.email,
attestationType: 'none',
excludeCredentials: existingDevices.map(dev => ({
id: dev.credentialID, type: 'public-key',
})),
authenticatorSelection: {
residentKey: 'preferred',
userVerification: 'preferred',
},
});
req.session.currentChallenge = options.challenge;
res.json(options);
});
Verify: Endpoint returns JSON with challenge, rp, user, and pubKeyCredParams fields.
6. Implement WebAuthn assertion (login verification)
Verify the WebAuthn assertion during login after password authentication. [src4] [src7]
const { generateAuthenticationOptions, verifyAuthenticationResponse } = require('@simplewebauthn/server');
app.post('/webauthn/authenticate/verify', async (req, res) => {
const expectedChallenge = req.session.currentChallenge;
const authn = await getAuthenticatorById(req.body.id);
const verification = await verifyAuthenticationResponse({
response: req.body,
expectedChallenge,
expectedOrigin: 'https://yourdomain.com',
expectedRPID: 'yourdomain.com',
authenticator: authn,
});
if (verification.verified) {
await updateAuthenticatorCounter(authn.credentialID, verification.authenticationInfo.newCounter);
// Issue session with mfa_verified: true
}
});
Verify: Authenticate with registered security key -- verification.verified === true.
Code Examples
Node.js (otplib): TOTP Setup and Verification
// Input: User email, app name, encryption key
// Output: QR code data URL, encrypted secret
const { authenticator } = require('otplib'); // v12.0.1
const QRCode = require('qrcode'); // v1.5.3
const crypto = require('crypto');
authenticator.options = { digits: 6, step: 30, window: 1 };
async function setupTOTP(userEmail, appName, encryptionKey) {
const secret = authenticator.generateSecret(20);
const uri = authenticator.keyuri(userEmail, appName, secret);
const qr = await QRCode.toDataURL(uri);
const iv = crypto.randomBytes(12);
const cipher = crypto.createCipheriv('aes-256-gcm', encryptionKey, iv);
let enc = cipher.update(secret, 'utf8', 'hex') + cipher.final('hex');
const tag = cipher.getAuthTag().toString('hex');
return { qrCodeDataUrl: qr, encryptedSecret: `${iv.toString('hex')}:${tag}:${enc}` };
}
function verifyTOTP(token, secret) {
return authenticator.check(token, secret);
}
Python (pyotp): TOTP Setup and Verification
# Input: User email, issuer name
# Output: Provisioning URI, base32 secret, verification result
import pyotp # v2.9.0
from cryptography.fernet import Fernet # v42.0.0
def setup_totp(user_email: str, issuer: str) -> dict:
secret = pyotp.random_base32(length=32)
totp = pyotp.TOTP(secret)
uri = totp.provisioning_uri(name=user_email, issuer_name=issuer)
return {"secret": secret, "provisioning_uri": uri}
def verify_totp(token: str, secret: str) -> bool:
totp = pyotp.TOTP(secret)
return totp.verify(token, valid_window=1)
def encrypt_secret(secret: str, key: bytes) -> str:
return Fernet(key).encrypt(secret.encode()).decode()
def decrypt_secret(encrypted: str, key: bytes) -> str:
return Fernet(key).decrypt(encrypted.encode()).decode()
WebAuthn (SimpleWebAuthn): Browser-Side Registration
// Input: Registration options from server
// Output: Registration response to send back to server
import { startRegistration } from '@simplewebauthn/browser'; // v11+
async function registerWebAuthn() {
const optionsRes = await fetch('/webauthn/register/options');
const options = await optionsRes.json();
const registration = await startRegistration(options);
const verifyRes = await fetch('/webauthn/register/verify', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(registration),
});
const result = await verifyRes.json();
if (result.verified) console.log('WebAuthn registration successful');
}
Anti-Patterns
Wrong: Storing TOTP secrets in plaintext
// BAD -- plaintext secret; if DB is breached, all MFA is compromised
await db.query(
'INSERT INTO users (email, totp_secret) VALUES ($1, $2)',
[email, secret]
);
Correct: Encrypting TOTP secrets at rest
// GOOD -- encrypted; DB breach does not expose secrets
const encryptedSecret = encryptSecret(secret, ENCRYPTION_KEY);
await db.query(
'INSERT INTO users (email, totp_secret_encrypted) VALUES ($1, $2)',
[email, encryptedSecret]
);
Wrong: No rate limiting on MFA verification
// BAD -- unlimited attempts allow brute-force of 1M combinations
app.post('/verify-mfa', (req, res) => {
const isValid = authenticator.check(req.body.token, user.secret);
res.json({ valid: isValid });
});
Correct: Rate-limited MFA verification
// GOOD -- 3 attempts per 60 seconds + audit logging
app.post('/verify-mfa', rateLimit({ windowMs: 60000, max: 3 }), (req, res) => {
const isValid = authenticator.check(req.body.token, user.secret);
if (!isValid) auditLog('mfa_failed', { userId: user.id, ip: req.ip });
res.json({ valid: isValid });
});
Wrong: Using SMS as the sole second factor
// BAD -- SMS-only is vulnerable to SIM swap and SS7 interception
async function verifyMFA(user, code) {
const sent = await twilioClient.verify.services(sid)
.verificationChecks.create({ to: user.phone, code });
return sent.status === 'approved';
}
Correct: SMS as fallback with TOTP as primary
// GOOD -- TOTP primary, SMS only as fallback with audit trail
async function verifyMFA(user, code, method) {
if (method === 'totp') {
return authenticator.check(code, decryptSecret(user.totp_secret));
}
if (method === 'sms' && !user.totp_enabled) {
const result = await verifySmsOtp(user.phone, code);
auditLog('sms_mfa_used', { userId: user.id, reason: 'totp_not_enrolled' });
return result;
}
return false;
}
Common Pitfalls
- Clock drift breaks TOTP: Server and user device clocks diverge by more than 30 seconds, causing valid codes to be rejected. Fix: Use
window: 1in otplib and ensure server NTP is synced. [src3] - QR code encoding errors: Using the wrong
otpauth://URI format causes authenticator apps to reject the secret. Fix: Always use the library'skeyuri()method; never construct the URI manually. [src3] - Backup codes stored unhashed: If the database is compromised, attackers can bypass MFA using recovery codes. Fix: Hash each backup code with bcrypt (cost 12+) or argon2id before storage. [src5]
- MFA bypass via session fixation: Attacker reuses a pre-MFA session token. Fix: Regenerate session ID after MFA verification and bind MFA status to the new session. [src5]
- WebAuthn rpID mismatch: Setting
rpIDtowww.example.comwhen authenticating fromexample.comsilently fails. Fix: SetrpIDto the registrable domain (example.com). [src4] - TOTP replay attack: Accepting the same code twice within its validity window. Fix: Track last-used timestamp per user and reject codes with equal or earlier timestamps. [src3]
- Push notification fatigue attack: Repeated push prompts until user accidentally approves. Fix: Implement number matching instead of simple approve/deny. [src2]
Diagnostic Commands
# Test TOTP generation locally (Node.js)
node -e "const {authenticator}=require('otplib'); const s=authenticator.generateSecret(); console.log('Secret:',s,'Token:',authenticator.generate(s))"
# Verify server NTP synchronization (critical for TOTP)
timedatectl status | grep "synchronized"
# Check otplib / pyotp version
npm list otplib 2>/dev/null || pip show pyotp 2>/dev/null
# Test WebAuthn endpoint availability
curl -s https://yourdomain.com/webauthn/register/options -H "Authorization: Bearer TOKEN" | jq '.challenge'
# Verify HTTPS is enabled (required for WebAuthn)
curl -sI https://yourdomain.com | grep -i "strict-transport-security"
# Check rate limit headers on MFA endpoint
curl -sI -X POST https://yourdomain.com/verify-mfa | grep -i "x-ratelimit"
Version History & Compatibility
| Standard/Library | Version | Status | Key Changes |
|---|---|---|---|
| NIST SP 800-63-4 | 4.0 | Current (Aug 2025) | Deprecated email OTP, downgraded SMS, added passkey support at AAL2 |
| NIST SP 800-63-3 | 3.0 | Superseded | Original MFA assurance levels (AAL1-AAL3) |
| RFC 6238 (TOTP) | 1.0 | Active (2011) | Stable -- no revisions planned |
| W3C WebAuthn | Level 2 | Current (2021) | Added cross-origin iframes, large blob storage |
| W3C WebAuthn | Level 3 | Draft (2024) | Conditional mediation, related origin requests |
| otplib (Node.js) | 12.x | Current | Modular architecture, tree-shaking support |
| pyotp (Python) | 2.9.x | Current | Steam guard support, type hints |
| SimpleWebAuthn | 11.x | Current | ESM-first, Deno/Bun support |
When to Use / When Not to Use
| Use When | Don't Use When | Use Instead |
|---|---|---|
| Protecting user accounts with sensitive data (financial, health, PII) | Machine-to-machine API authentication | Mutual TLS or API key + HMAC signing |
| Compliance requires MFA (PCI DSS, HIPAA, SOC 2, NIST 800-171) | Single-use anonymous interactions (no account) | No authentication needed |
| Login from untrusted devices or networks | Users on shared/kiosk devices without personal authenticators | Device certificates or SSO with IdP-managed MFA |
| Step-up authentication for high-risk operations | Internal microservice calls behind a service mesh | Service mesh mTLS (Istio, Linkerd) |
| Enterprise or B2B applications with compliance requirements | Friction-sensitive consumer onboarding | Progressive MFA (offer after first login, require after risk signal) |
Important Caveats
- NIST SP 800-63-4 (August 2025) significantly changed the MFA landscape: email OTP is deprecated, SMS is restricted, and passkeys are now explicitly accepted at AAL2. Implementations built to SP 800-63-3 may need updates.
- WebAuthn requires HTTPS and a secure context. It will not work on
http://localhostin production-like testing -- uselocalhost(special-cased by browsers) or a proper TLS certificate. - TOTP is not phishing-resistant: a real-time phishing proxy (e.g., Evilginx) can capture and replay TOTP codes within their validity window. For phishing resistance, WebAuthn is the only standards-based option.
- Biometric data in WebAuthn never leaves the user's device -- the server only stores public keys and credential IDs. Do not attempt to store or transmit biometric templates.
- Backup code UX matters: users who do not save their backup codes during enrollment will be locked out when they lose their authenticator. Consider requiring confirmation of at least one backup code during setup.
- Push notification MFA is vulnerable to "MFA fatigue" attacks (repeated prompts until user approves). Microsoft and Okta now recommend number matching as the default.