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/qrcodeImplementation#
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#
- Encrypt secrets at rest - Never store TOTP secrets in plaintext
- Provide backup codes - Generate one-time recovery codes
- Allow clock drift - Use window option for time variance
- Rate limit attempts - Prevent brute force attacks
- Log MFA events - Track enable, disable, and verification attempts
Related Patterns#
- Session Management - Session handling after MFA
- RBAC - Role-based access with MFA requirements
- JWT - Token handling for MFA state