Multi-Factor Authentication (MFA)

Patterns for implementing MFA/2FA with TOTP and backup codes.

Overview#

MFA adds security layers with:

  • TOTP (Time-based One-Time Passwords)
  • Authenticator app integration
  • Backup recovery codes
  • SMS fallback (optional)

Prerequisites:

  • User authentication system
  • QR code generation
  • Secure secret storage

Installation#

npm install otplib qrcode npm install -D @types/qrcode

Implementation#

TOTP Setup#

1// lib/mfa.ts 2import { authenticator } from 'otplib' 3import QRCode from 'qrcode' 4import { prisma } from '@/lib/db' 5 6authenticator.options = { 7 window: 1 // Allow 1 step variance for clock drift 8} 9 10export async function generateMfaSecret(userId: string, email: string) { 11 const secret = authenticator.generateSecret() 12 13 // Store encrypted secret 14 await prisma.user.update({ 15 where: { id: userId }, 16 data: { 17 mfaSecret: encrypt(secret), 18 mfaEnabled: false // Not enabled until verified 19 } 20 }) 21 22 // Generate QR code 23 const otpauth = authenticator.keyuri(email, 'MyApp', secret) 24 const qrCode = await QRCode.toDataURL(otpauth) 25 26 return { secret, qrCode } 27} 28 29export async function verifyAndEnableMfa(userId: string, token: string) { 30 const user = await prisma.user.findUnique({ 31 where: { id: userId } 32 }) 33 34 if (!user?.mfaSecret) { 35 throw new Error('MFA not set up') 36 } 37 38 const secret = decrypt(user.mfaSecret) 39 const isValid = authenticator.verify({ token, secret }) 40 41 if (!isValid) { 42 throw new Error('Invalid verification code') 43 } 44 45 // Generate backup codes 46 const backupCodes = generateBackupCodes() 47 48 await prisma.user.update({ 49 where: { id: userId }, 50 data: { 51 mfaEnabled: true, 52 mfaBackupCodes: backupCodes.map(code => hashBackupCode(code)) 53 } 54 }) 55 56 return backupCodes 57} 58 59export function verifyTotp(secret: string, token: string): boolean { 60 return authenticator.verify({ token, secret }) 61} 62 63function generateBackupCodes(count = 10): string[] { 64 return Array.from({ length: count }, () => 65 Math.random().toString(36).slice(2, 10).toUpperCase() 66 ) 67}

MFA Setup Flow#

1// app/settings/security/mfa/page.tsx 2'use client' 3 4import { useState } from 'react' 5import Image from 'next/image' 6 7export default function MfaSetupPage() { 8 const [step, setStep] = useState<'setup' | 'verify' | 'backup'>('setup') 9 const [qrCode, setQrCode] = useState<string>() 10 const [secret, setSecret] = useState<string>() 11 const [backupCodes, setBackupCodes] = useState<string[]>() 12 const [verifyCode, setVerifyCode] = useState('') 13 const [error, setError] = useState('') 14 15 async function startSetup() { 16 const res = await fetch('/api/mfa/setup', { method: 'POST' }) 17 const data = await res.json() 18 19 setQrCode(data.qrCode) 20 setSecret(data.secret) 21 setStep('verify') 22 } 23 24 async function verifyAndEnable() { 25 setError('') 26 27 const res = await fetch('/api/mfa/verify', { 28 method: 'POST', 29 body: JSON.stringify({ token: verifyCode }) 30 }) 31 32 if (!res.ok) { 33 setError('Invalid code. Please try again.') 34 return 35 } 36 37 const data = await res.json() 38 setBackupCodes(data.backupCodes) 39 setStep('backup') 40 } 41 42 if (step === 'setup') { 43 return ( 44 <div className="max-w-md"> 45 <h1 className="mb-4 text-2xl font-bold">Enable Two-Factor Auth</h1> 46 <p className="mb-6 text-gray-600"> 47 Add an extra layer of security to your account. 48 </p> 49 <button onClick={startSetup} className="rounded bg-blue-600 px-4 py-2 text-white"> 50 Set Up 2FA 51 </button> 52 </div> 53 ) 54 } 55 56 if (step === 'verify') { 57 return ( 58 <div className="max-w-md"> 59 <h1 className="mb-4 text-2xl font-bold">Scan QR Code</h1> 60 <p className="mb-4 text-gray-600"> 61 Scan this QR code with your authenticator app. 62 </p> 63 64 {qrCode && ( 65 <Image src={qrCode} alt="QR Code" width={200} height={200} className="mb-4" /> 66 )} 67 68 <p className="mb-4 text-sm text-gray-500"> 69 Or enter manually: <code className="bg-gray-100 px-2 py-1">{secret}</code> 70 </p> 71 72 <div className="mb-4"> 73 <label className="mb-1 block text-sm font-medium">Verification Code</label> 74 <input 75 type="text" 76 value={verifyCode} 77 onChange={(e) => setVerifyCode(e.target.value)} 78 placeholder="Enter 6-digit code" 79 className="w-full rounded border px-3 py-2" 80 /> 81 {error && <p className="mt-1 text-sm text-red-500">{error}</p>} 82 </div> 83 84 <button onClick={verifyAndEnable} className="rounded bg-blue-600 px-4 py-2 text-white"> 85 Verify and Enable 86 </button> 87 </div> 88 ) 89 } 90 91 return ( 92 <div className="max-w-md"> 93 <h1 className="mb-4 text-2xl font-bold">Save Backup Codes</h1> 94 <p className="mb-4 text-gray-600"> 95 Save these codes in a secure place. You can use them if you lose access to your authenticator. 96 </p> 97 98 <div className="mb-6 grid grid-cols-2 gap-2 rounded bg-gray-100 p-4 font-mono"> 99 {backupCodes?.map((code, i) => ( 100 <div key={i}>{code}</div> 101 ))} 102 </div> 103 104 <a href="/settings/security" className="rounded bg-blue-600 px-4 py-2 text-white"> 105 Done 106 </a> 107 </div> 108 ) 109}

MFA Login Challenge#

1// app/login/mfa/page.tsx 2'use client' 3 4import { useState } from 'react' 5import { useRouter, useSearchParams } from 'next/navigation' 6 7export default function MfaChallengePage() { 8 const router = useRouter() 9 const searchParams = useSearchParams() 10 const [code, setCode] = useState('') 11 const [error, setError] = useState('') 12 const [useBackup, setUseBackup] = useState(false) 13 14 async function handleSubmit(e: React.FormEvent) { 15 e.preventDefault() 16 setError('') 17 18 const res = await fetch('/api/auth/mfa-verify', { 19 method: 'POST', 20 body: JSON.stringify({ 21 code, 22 isBackupCode: useBackup, 23 token: searchParams.get('token') 24 }) 25 }) 26 27 if (!res.ok) { 28 setError('Invalid code') 29 return 30 } 31 32 router.push('/dashboard') 33 } 34 35 return ( 36 <form onSubmit={handleSubmit} className="mx-auto max-w-md py-12"> 37 <h1 className="mb-6 text-2xl font-bold">Two-Factor Authentication</h1> 38 39 <div className="mb-4"> 40 <label className="mb-1 block text-sm font-medium"> 41 {useBackup ? 'Backup Code' : 'Authentication Code'} 42 </label> 43 <input 44 type="text" 45 value={code} 46 onChange={(e) => setCode(e.target.value)} 47 placeholder={useBackup ? 'Enter backup code' : 'Enter 6-digit code'} 48 className="w-full rounded border px-3 py-2" 49 autoFocus 50 /> 51 {error && <p className="mt-1 text-sm text-red-500">{error}</p>} 52 </div> 53 54 <button type="submit" className="w-full rounded bg-blue-600 py-2 text-white"> 55 Verify 56 </button> 57 58 <button 59 type="button" 60 onClick={() => setUseBackup(!useBackup)} 61 className="mt-4 text-sm text-blue-600" 62 > 63 {useBackup ? 'Use authenticator code' : 'Use backup code'} 64 </button> 65 </form> 66 ) 67}

SMS Fallback#

1// lib/mfa-sms.ts 2import twilio from 'twilio' 3 4const client = twilio( 5 process.env.TWILIO_ACCOUNT_SID, 6 process.env.TWILIO_AUTH_TOKEN 7) 8 9export async function sendSmsCode(phoneNumber: string, code: string) { 10 await client.messages.create({ 11 body: `Your verification code is: ${code}`, 12 from: process.env.TWILIO_PHONE_NUMBER, 13 to: phoneNumber 14 }) 15} 16 17export async function generateAndSendSmsCode(userId: string) { 18 const code = Math.floor(100000 + Math.random() * 900000).toString() 19 20 const user = await prisma.user.findUnique({ 21 where: { id: userId }, 22 select: { phone: true } 23 }) 24 25 if (!user?.phone) { 26 throw new Error('No phone number') 27 } 28 29 // Store code with expiry 30 await prisma.verificationCode.create({ 31 data: { 32 userId, 33 code: hashCode(code), 34 type: 'SMS_MFA', 35 expiresAt: new Date(Date.now() + 5 * 60 * 1000) // 5 minutes 36 } 37 }) 38 39 await sendSmsCode(user.phone, code) 40}

API Routes for MFA#

1// app/api/mfa/setup/route.ts 2import { NextResponse } from 'next/server' 3import { auth } from '@/auth' 4import { generateMfaSecret } from '@/lib/mfa' 5 6export async function POST() { 7 const session = await auth() 8 9 if (!session?.user) { 10 return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) 11 } 12 13 const { qrCode, secret } = await generateMfaSecret( 14 session.user.id, 15 session.user.email 16 ) 17 18 return NextResponse.json({ qrCode, secret }) 19}
1// app/api/mfa/verify/route.ts 2import { NextResponse } from 'next/server' 3import { auth } from '@/auth' 4import { verifyAndEnableMfa } from '@/lib/mfa' 5 6export async function POST(request: Request) { 7 const session = await auth() 8 9 if (!session?.user) { 10 return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) 11 } 12 13 const { token } = await request.json() 14 15 try { 16 const backupCodes = await verifyAndEnableMfa(session.user.id, token) 17 return NextResponse.json({ backupCodes }) 18 } catch (error) { 19 return NextResponse.json( 20 { error: 'Invalid verification code' }, 21 { status: 400 } 22 ) 23 } 24}

Best Practices#

  1. Encrypt secrets at rest - Never store TOTP secrets in plaintext
  2. Provide backup codes - Generate one-time recovery codes
  3. Allow clock drift - Use window option for time variance
  4. Rate limit attempts - Prevent brute force attacks
  5. Log MFA events - Track enable, disable, and verification attempts
  • Session Management - Session handling after MFA
  • RBAC - Role-based access with MFA requirements
  • JWT - Token handling for MFA state