otplib (Node.js) / pyotp (Python) for TOTP; @simplewebauthn/server for WebAuthn.| 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 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) |
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
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.
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.
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.
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.
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.
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.
// 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);
}
# 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()
// 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');
}
// 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]
);
// 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]
);
// 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 });
});
// 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 });
});
// 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';
}
// 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;
}
window: 1 in otplib and ensure server NTP is synced. [src3]otpauth:// URI format causes authenticator apps to reject the secret. Fix: Always use the library's keyuri() method; never construct the URI manually. [src3]rpID to www.example.com when authenticating from example.com silently fails. Fix: Set rpID to the registrable domain (example.com). [src4]# 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"
| 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 |
| 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) |
http://localhost in production-like testing -- use localhost (special-cased by browsers) or a proper TLS certificate.