How to Implement Two-Factor Authentication (2FA)
Add an extra layer of security with TOTP authenticator apps
TL;DR
TL;DR (30 minutes): Use TOTP (Time-based One-Time Password) with libraries like otplib . Generate 20+ byte secrets, store encrypted, display as QR code. Require code verification before enabling. Generate 10 backup codes, hash before storing. On login, verify TOTP after password, not before.
Prerequisites:
- Working authentication system
- QR code generation capability
- Encryption for storing secrets
Why This Matters
Two-factor authentication prevents account takeover even when passwords are compromised. Google reports that 2FA blocks 100% of automated bots, 99% of bulk phishing attacks, and 66% of targeted attacks. For sensitive apps, 2FA should be mandatory.
Step-by-Step Guide
Install dependencies
npm install otplib qrcode
# otplib - TOTP/HOTP implementation
# qrcode - Generate QR codes for authenticator setup
Database schema for 2FA
// Prisma schema
model User {
id String @id @default(cuid())
email String @unique
passwordHash String
twoFactorSecret String? // Encrypted TOTP secret
twoFactorEnabled Boolean @default(false)
backupCodes BackupCode[]
}
model BackupCode {
id String @id @default(cuid())
userId String
user User @relation(fields: [userId], references: [id])
codeHash String // Hashed backup code
usedAt DateTime?
createdAt DateTime @default(now())
@@index([userId])
}
Generate TOTP secret and QR code
import { authenticator } from 'otplib';
import QRCode from 'qrcode';
// Configure TOTP settings
authenticator.options = {
digits: 6,
step: 30, // 30 second window
window: 1 // Allow 1 step before/after for clock drift
};
async function setupTwoFactor(userId: string) {
const user = await prisma.user.findUnique({
where: { id: userId }
});
if (user.twoFactorEnabled) {
throw new Error('2FA is already enabled');
}
// Generate new secret
const secret = authenticator.generateSecret(20); // 20 bytes = 160 bits
// Create otpauth URL for authenticator apps
const otpauthUrl = authenticator.keyuri(
user.email,
'MyApp', // Your app name (shows in authenticator)
secret
);
// Generate QR code as data URL
const qrCodeDataUrl = await QRCode.toDataURL(otpauthUrl);
// Store encrypted secret (not enabled yet)
await prisma.user.update({
where: { id: userId },
data: {
twoFactorSecret: encrypt(secret)
// Don't enable yet - wait for verification
}
});
return {
secret, // Show to user for manual entry
qrCode: qrCodeDataUrl
};
}
Verify and enable 2FA
async function verifyAndEnableTwoFactor(userId: string, code: string) {
const user = await prisma.user.findUnique({
where: { id: userId }
});
if (!user.twoFactorSecret) {
throw new Error('2FA setup not started');
}
if (user.twoFactorEnabled) {
throw new Error('2FA is already enabled');
}
const secret = decrypt(user.twoFactorSecret);
// Verify the code
const isValid = authenticator.verify({
token: code,
secret
});
if (!isValid) {
throw new Error('Invalid verification code');
}
// Generate backup codes
const backupCodes = await generateBackupCodes(userId);
// Enable 2FA
await prisma.user.update({
where: { id: userId },
data: { twoFactorEnabled: true }
});
return {
success: true,
backupCodes // Show these once, user must save them
};
}
Generate backup codes
import crypto from 'crypto';
async function generateBackupCodes(userId: string) {
// Delete any existing unused codes
await prisma.backupCode.deleteMany({
where: { userId, usedAt: null }
});
// Generate 10 new backup codes
const codes = [];
const codeData = [];
for (let i = 0; i < 10; i++) {
// Generate readable code: XXXX-XXXX format
const code = `${randomDigits(4)}-${randomDigits(4)}`;
codes.push(code);
codeData.push({
userId,
codeHash: hashBackupCode(code)
});
}
// Store hashed codes
await prisma.backupCode.createMany({
data: codeData
});
return codes; // Return raw codes to show user
}
function randomDigits(length: number): string {
const digits = '0123456789';
let result = '';
const bytes = crypto.randomBytes(length);
for (let i = 0; i < length; i++) {
result += digits[bytes[i] % 10];
}
return result;
}
function hashBackupCode(code: string): string {
// Normalize: remove dashes, uppercase
const normalized = code.replace(/-/g, '').toUpperCase();
return crypto.createHash('sha256').update(normalized).digest('hex');
}
Verify 2FA on login
async function loginWithTwoFactor(req, res) {
const { email, password, twoFactorCode, backupCode } = req.body;
// Step 1: Verify email/password
const user = await prisma.user.findUnique({
where: { email }
});
if (!user || !await bcrypt.compare(password, user.passwordHash)) {
return res.status(401).json({ error: 'Invalid credentials' });
}
// Step 2: Check if 2FA is enabled
if (!user.twoFactorEnabled) {
// No 2FA, create session directly
const session = await createSession(user.id);
return res.json({ success: true, session });
}
// Step 3: 2FA is required
if (!twoFactorCode && !backupCode) {
// Tell client to show 2FA input
return res.json({
requiresTwoFactor: true,
userId: user.id // Or use a temporary token
});
}
// Step 4: Verify 2FA code
if (twoFactorCode) {
const secret = decrypt(user.twoFactorSecret);
const isValid = authenticator.verify({
token: twoFactorCode,
secret
});
if (!isValid) {
return res.status(401).json({ error: 'Invalid 2FA code' });
}
}
// Step 5: Or verify backup code
else if (backupCode) {
const codeHash = hashBackupCode(backupCode);
const storedCode = await prisma.backupCode.findFirst({
where: {
userId: user.id,
codeHash,
usedAt: null
}
});
if (!storedCode) {
return res.status(401).json({ error: 'Invalid backup code' });
}
// Mark backup code as used
await prisma.backupCode.update({
where: { id: storedCode.id },
data: { usedAt: new Date() }
});
// Warn user about remaining codes
const remainingCodes = await prisma.backupCode.count({
where: { userId: user.id, usedAt: null }
});
if (remainingCodes <= 2) {
// Consider notifying user to generate new codes
}
}
// Create session
const session = await createSession(user.id);
return res.json({ success: true, session });
}
Disable 2FA (with verification)
async function disableTwoFactor(userId: string, code: string, password: string) {
const user = await prisma.user.findUnique({
where: { id: userId }
});
// Require password verification
const passwordValid = await bcrypt.compare(password, user.passwordHash);
if (!passwordValid) {
throw new Error('Invalid password');
}
// Verify current 2FA code
const secret = decrypt(user.twoFactorSecret);
const isValid = authenticator.verify({ token: code, secret });
if (!isValid) {
throw new Error('Invalid 2FA code');
}
// Disable 2FA
await prisma.$transaction([
prisma.user.update({
where: { id: userId },
data: {
twoFactorEnabled: false,
twoFactorSecret: null
}
}),
prisma.backupCode.deleteMany({
where: { userId }
})
]);
return { success: true };
}
2FA Security Checklist:
- Store TOTP secrets encrypted, not plaintext
- Require current 2FA code to disable 2FA
- Hash backup codes, don't store plaintext
- Backup codes are single-use only
- Verify password AND 2FA, not just 2FA
- Use window: 1 for clock drift tolerance (not higher)
- Rate limit 2FA verification attempts
- Consider requiring 2FA for sensitive actions even when logged in
How to Verify It Worked
- Test setup flow: Scan QR code with Google Authenticator, verify code works
- Test login: Log out, verify 2FA is required on next login
- Test backup codes: Use a backup code, verify it can't be reused
- Test clock drift: Codes from previous/next 30-second window should work
- Test disable: Verify 2FA can only be disabled with valid code + password
Common Errors & Troubleshooting
Codes always invalid
Check server time is synchronized (NTP). TOTP is time-sensitive - even a minute of drift causes failures.
QR code not scanning
Verify the otpauth URL format. Try manual entry with the secret to test.
Backup codes not working
Check normalization (remove dashes, case-insensitive comparison). Verify codes aren't already marked as used.
Users locked out
Have an admin recovery process (verify identity through other means, then disable 2FA).
TOTP vs SMS - which is more secure?
TOTP is more secure. SMS is vulnerable to SIM swapping attacks and SS7 exploits. Use TOTP (authenticator apps) or hardware keys (WebAuthn). Only use SMS as a fallback if necessary.
Should 2FA be mandatory?
For sensitive apps (financial, healthcare, admin), yes. For general apps, strongly encourage but don't force. Balance security with user experience based on your risk profile.
How do I handle user recovery when they lose their 2FA device?
Backup codes are the primary method. For users who lost those too, require strong identity verification (government ID, video call, security questions) before disabling 2FA manually.
Related guides:Secure Login Form · Session Management · Password Reset Security