[{"data":1,"prerenderedAt":352},["ShallowReactive",2],{"blog-how-to/two-factor-auth":3},{"id":4,"title":5,"body":6,"category":332,"date":333,"dateModified":334,"description":335,"draft":336,"extension":337,"faq":338,"featured":336,"headerVariant":339,"image":338,"keywords":338,"meta":340,"navigation":341,"ogDescription":342,"ogTitle":338,"path":343,"readTime":338,"schemaOrg":344,"schemaType":345,"seo":346,"sitemap":347,"stem":348,"tags":349,"twitterCard":350,"__hash__":351},"blog/blog/how-to/two-factor-auth.md","How to Implement Two-Factor Authentication (2FA)",{"type":7,"value":8,"toc":316},"minimark",[9,13,17,21,27,30,43,48,51,55,75,88,101,114,127,140,153,185,189,223,227,232,235,239,242,246,249,253,256,278,297],[10,11],"category-badge",{"category":12},"How-To Guide",[14,15,5],"h1",{"id":16},"how-to-implement-two-factor-authentication-2fa",[18,19,20],"p",{},"Add an extra layer of security with TOTP authenticator apps",[22,23,24],"tldr",{},[18,25,26],{},"TL;DR (30 minutes):\nUse TOTP (Time-based One-Time Password) with libraries like\notplib\n. 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.",[18,28,29],{},"Prerequisites:",[31,32,33,37,40],"ul",{},[34,35,36],"li",{},"Working authentication system",[34,38,39],{},"QR code generation capability",[34,41,42],{},"Encryption for storing secrets",[44,45,47],"h2",{"id":46},"why-this-matters","Why This Matters",[18,49,50],{},"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.",[44,52,54],{"id":53},"step-by-step-guide","Step-by-Step Guide",[56,57,59,64],"step",{"number":58},"1",[60,61,63],"h3",{"id":62},"install-dependencies","Install dependencies",[65,66,71],"pre",{"className":67,"code":69,"language":70},[68],"language-text","npm install otplib qrcode\n\n# otplib - TOTP/HOTP implementation\n# qrcode - Generate QR codes for authenticator setup\n","text",[72,73,69],"code",{"__ignoreMap":74},"",[56,76,78,82],{"number":77},"2",[60,79,81],{"id":80},"database-schema-for-2fa","Database schema for 2FA",[65,83,86],{"className":84,"code":85,"language":70},[68],"// Prisma schema\nmodel User {\n  id              String   @id @default(cuid())\n  email           String   @unique\n  passwordHash    String\n  twoFactorSecret String?  // Encrypted TOTP secret\n  twoFactorEnabled Boolean @default(false)\n  backupCodes     BackupCode[]\n}\n\nmodel BackupCode {\n  id        String   @id @default(cuid())\n  userId    String\n  user      User     @relation(fields: [userId], references: [id])\n  codeHash  String   // Hashed backup code\n  usedAt    DateTime?\n  createdAt DateTime @default(now())\n\n  @@index([userId])\n}\n",[72,87,85],{"__ignoreMap":74},[56,89,91,95],{"number":90},"3",[60,92,94],{"id":93},"generate-totp-secret-and-qr-code","Generate TOTP secret and QR code",[65,96,99],{"className":97,"code":98,"language":70},[68],"import { authenticator } from 'otplib';\nimport QRCode from 'qrcode';\n\n// Configure TOTP settings\nauthenticator.options = {\n  digits: 6,\n  step: 30,  // 30 second window\n  window: 1  // Allow 1 step before/after for clock drift\n};\n\nasync function setupTwoFactor(userId: string) {\n  const user = await prisma.user.findUnique({\n    where: { id: userId }\n  });\n\n  if (user.twoFactorEnabled) {\n    throw new Error('2FA is already enabled');\n  }\n\n  // Generate new secret\n  const secret = authenticator.generateSecret(20); // 20 bytes = 160 bits\n\n  // Create otpauth URL for authenticator apps\n  const otpauthUrl = authenticator.keyuri(\n    user.email,\n    'MyApp',  // Your app name (shows in authenticator)\n    secret\n  );\n\n  // Generate QR code as data URL\n  const qrCodeDataUrl = await QRCode.toDataURL(otpauthUrl);\n\n  // Store encrypted secret (not enabled yet)\n  await prisma.user.update({\n    where: { id: userId },\n    data: {\n      twoFactorSecret: encrypt(secret)\n      // Don't enable yet - wait for verification\n    }\n  });\n\n  return {\n    secret,  // Show to user for manual entry\n    qrCode: qrCodeDataUrl\n  };\n}\n",[72,100,98],{"__ignoreMap":74},[56,102,104,108],{"number":103},"4",[60,105,107],{"id":106},"verify-and-enable-2fa","Verify and enable 2FA",[65,109,112],{"className":110,"code":111,"language":70},[68],"async function verifyAndEnableTwoFactor(userId: string, code: string) {\n  const user = await prisma.user.findUnique({\n    where: { id: userId }\n  });\n\n  if (!user.twoFactorSecret) {\n    throw new Error('2FA setup not started');\n  }\n\n  if (user.twoFactorEnabled) {\n    throw new Error('2FA is already enabled');\n  }\n\n  const secret = decrypt(user.twoFactorSecret);\n\n  // Verify the code\n  const isValid = authenticator.verify({\n    token: code,\n    secret\n  });\n\n  if (!isValid) {\n    throw new Error('Invalid verification code');\n  }\n\n  // Generate backup codes\n  const backupCodes = await generateBackupCodes(userId);\n\n  // Enable 2FA\n  await prisma.user.update({\n    where: { id: userId },\n    data: { twoFactorEnabled: true }\n  });\n\n  return {\n    success: true,\n    backupCodes  // Show these once, user must save them\n  };\n}\n",[72,113,111],{"__ignoreMap":74},[56,115,117,121],{"number":116},"5",[60,118,120],{"id":119},"generate-backup-codes","Generate backup codes",[65,122,125],{"className":123,"code":124,"language":70},[68],"import crypto from 'crypto';\n\nasync function generateBackupCodes(userId: string) {\n  // Delete any existing unused codes\n  await prisma.backupCode.deleteMany({\n    where: { userId, usedAt: null }\n  });\n\n  // Generate 10 new backup codes\n  const codes = [];\n  const codeData = [];\n\n  for (let i = 0; i \u003C 10; i++) {\n    // Generate readable code: XXXX-XXXX format\n    const code = `${randomDigits(4)}-${randomDigits(4)}`;\n    codes.push(code);\n\n    codeData.push({\n      userId,\n      codeHash: hashBackupCode(code)\n    });\n  }\n\n  // Store hashed codes\n  await prisma.backupCode.createMany({\n    data: codeData\n  });\n\n  return codes;  // Return raw codes to show user\n}\n\nfunction randomDigits(length: number): string {\n  const digits = '0123456789';\n  let result = '';\n  const bytes = crypto.randomBytes(length);\n  for (let i = 0; i \u003C length; i++) {\n    result += digits[bytes[i] % 10];\n  }\n  return result;\n}\n\nfunction hashBackupCode(code: string): string {\n  // Normalize: remove dashes, uppercase\n  const normalized = code.replace(/-/g, '').toUpperCase();\n  return crypto.createHash('sha256').update(normalized).digest('hex');\n}\n",[72,126,124],{"__ignoreMap":74},[56,128,130,134],{"number":129},"6",[60,131,133],{"id":132},"verify-2fa-on-login","Verify 2FA on login",[65,135,138],{"className":136,"code":137,"language":70},[68],"async function loginWithTwoFactor(req, res) {\n  const { email, password, twoFactorCode, backupCode } = req.body;\n\n  // Step 1: Verify email/password\n  const user = await prisma.user.findUnique({\n    where: { email }\n  });\n\n  if (!user || !await bcrypt.compare(password, user.passwordHash)) {\n    return res.status(401).json({ error: 'Invalid credentials' });\n  }\n\n  // Step 2: Check if 2FA is enabled\n  if (!user.twoFactorEnabled) {\n    // No 2FA, create session directly\n    const session = await createSession(user.id);\n    return res.json({ success: true, session });\n  }\n\n  // Step 3: 2FA is required\n  if (!twoFactorCode && !backupCode) {\n    // Tell client to show 2FA input\n    return res.json({\n      requiresTwoFactor: true,\n      userId: user.id  // Or use a temporary token\n    });\n  }\n\n  // Step 4: Verify 2FA code\n  if (twoFactorCode) {\n    const secret = decrypt(user.twoFactorSecret);\n    const isValid = authenticator.verify({\n      token: twoFactorCode,\n      secret\n    });\n\n    if (!isValid) {\n      return res.status(401).json({ error: 'Invalid 2FA code' });\n    }\n  }\n\n  // Step 5: Or verify backup code\n  else if (backupCode) {\n    const codeHash = hashBackupCode(backupCode);\n    const storedCode = await prisma.backupCode.findFirst({\n      where: {\n        userId: user.id,\n        codeHash,\n        usedAt: null\n      }\n    });\n\n    if (!storedCode) {\n      return res.status(401).json({ error: 'Invalid backup code' });\n    }\n\n    // Mark backup code as used\n    await prisma.backupCode.update({\n      where: { id: storedCode.id },\n      data: { usedAt: new Date() }\n    });\n\n    // Warn user about remaining codes\n    const remainingCodes = await prisma.backupCode.count({\n      where: { userId: user.id, usedAt: null }\n    });\n\n    if (remainingCodes \u003C= 2) {\n      // Consider notifying user to generate new codes\n    }\n  }\n\n  // Create session\n  const session = await createSession(user.id);\n  return res.json({ success: true, session });\n}\n",[72,139,137],{"__ignoreMap":74},[56,141,143,147],{"number":142},"7",[60,144,146],{"id":145},"disable-2fa-with-verification","Disable 2FA (with verification)",[65,148,151],{"className":149,"code":150,"language":70},[68],"async function disableTwoFactor(userId: string, code: string, password: string) {\n  const user = await prisma.user.findUnique({\n    where: { id: userId }\n  });\n\n  // Require password verification\n  const passwordValid = await bcrypt.compare(password, user.passwordHash);\n  if (!passwordValid) {\n    throw new Error('Invalid password');\n  }\n\n  // Verify current 2FA code\n  const secret = decrypt(user.twoFactorSecret);\n  const isValid = authenticator.verify({ token: code, secret });\n\n  if (!isValid) {\n    throw new Error('Invalid 2FA code');\n  }\n\n  // Disable 2FA\n  await prisma.$transaction([\n    prisma.user.update({\n      where: { id: userId },\n      data: {\n        twoFactorEnabled: false,\n        twoFactorSecret: null\n      }\n    }),\n    prisma.backupCode.deleteMany({\n      where: { userId }\n    })\n  ]);\n\n  return { success: true };\n}\n",[72,152,150],{"__ignoreMap":74},[154,155,156,159],"warning-box",{},[18,157,158],{},"2FA Security Checklist:",[31,160,161,164,167,170,173,176,179,182],{},[34,162,163],{},"Store TOTP secrets encrypted, not plaintext",[34,165,166],{},"Require current 2FA code to disable 2FA",[34,168,169],{},"Hash backup codes, don't store plaintext",[34,171,172],{},"Backup codes are single-use only",[34,174,175],{},"Verify password AND 2FA, not just 2FA",[34,177,178],{},"Use window: 1 for clock drift tolerance (not higher)",[34,180,181],{},"Rate limit 2FA verification attempts",[34,183,184],{},"Consider requiring 2FA for sensitive actions even when logged in",[44,186,188],{"id":187},"how-to-verify-it-worked","How to Verify It Worked",[190,191,192,199,205,211,217],"ol",{},[34,193,194,198],{},[195,196,197],"strong",{},"Test setup flow:"," Scan QR code with Google Authenticator, verify code works",[34,200,201,204],{},[195,202,203],{},"Test login:"," Log out, verify 2FA is required on next login",[34,206,207,210],{},[195,208,209],{},"Test backup codes:"," Use a backup code, verify it can't be reused",[34,212,213,216],{},[195,214,215],{},"Test clock drift:"," Codes from previous/next 30-second window should work",[34,218,219,222],{},[195,220,221],{},"Test disable:"," Verify 2FA can only be disabled with valid code + password",[44,224,226],{"id":225},"common-errors-troubleshooting","Common Errors & Troubleshooting",[228,229,231],"h4",{"id":230},"codes-always-invalid","Codes always invalid",[18,233,234],{},"Check server time is synchronized (NTP). TOTP is time-sensitive - even a minute of drift causes failures.",[228,236,238],{"id":237},"qr-code-not-scanning","QR code not scanning",[18,240,241],{},"Verify the otpauth URL format. Try manual entry with the secret to test.",[228,243,245],{"id":244},"backup-codes-not-working","Backup codes not working",[18,247,248],{},"Check normalization (remove dashes, case-insensitive comparison). Verify codes aren't already marked as used.",[228,250,252],{"id":251},"users-locked-out","Users locked out",[18,254,255],{},"Have an admin recovery process (verify identity through other means, then disable 2FA).",[257,258,259,266,272],"faq-section",{},[260,261,263],"faq-item",{"question":262},"TOTP vs SMS - which is more secure?",[18,264,265],{},"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.",[260,267,269],{"question":268},"Should 2FA be mandatory?",[18,270,271],{},"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.",[260,273,275],{"question":274},"How do I handle user recovery when they lose their 2FA device?",[18,276,277],{},"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.",[18,279,280,283,288,289,288,293],{},[195,281,282],{},"Related guides:",[284,285,287],"a",{"href":286},"/blog/how-to/secure-login-form","Secure Login Form"," ·\n",[284,290,292],{"href":291},"/blog/how-to/session-management","Session Management",[284,294,296],{"href":295},"/blog/how-to/password-reset-security","Password Reset Security",[298,299,300,306,311],"related-articles",{},[301,302],"related-card",{"description":303,"href":304,"title":305},"Complete guide to Content Security Policy setup. Learn CSP directives, implement nonces, configure reporting, and create","/blog/how-to/csp-setup","How to Set Up Content Security Policy (CSP)",[301,307],{"description":308,"href":309,"title":310},"Step-by-step guide to configuring SSL certificates for custom domains on Vercel, Netlify, and Cloudflare. Includes DNS c","/blog/how-to/custom-domain-ssl","How to Set Up SSL for Custom Domains",[301,312],{"description":313,"href":314,"title":315},"Step-by-step guide to implementing database audit logging. Track who accessed what data, when, and detect unauthorized a","/blog/how-to/database-audit-logs","How to Set Up Database Audit Logs",{"title":74,"searchDepth":317,"depth":317,"links":318},2,[319,320,330,331],{"id":46,"depth":317,"text":47},{"id":53,"depth":317,"text":54,"children":321},[322,324,325,326,327,328,329],{"id":62,"depth":323,"text":63},3,{"id":80,"depth":323,"text":81},{"id":93,"depth":323,"text":94},{"id":106,"depth":323,"text":107},{"id":119,"depth":323,"text":120},{"id":132,"depth":323,"text":133},{"id":145,"depth":323,"text":146},{"id":187,"depth":317,"text":188},{"id":225,"depth":317,"text":226},"how-to","2026-01-26","2026-02-10","Step-by-step guide to implementing TOTP-based two-factor authentication. Add 2FA with Google Authenticator, backup codes, and secure recovery.",false,"md",null,"yellow",{},true,"Add 2FA to your app with TOTP and backup codes.","/blog/how-to/two-factor-auth","[object Object]","HowTo",{"title":5,"description":335},{"loc":343},"blog/how-to/two-factor-auth",[],"summary_large_image","iydhlgMZagAez5pa7rXRsu04DcAUyDqsuGKZZY_BdYk",1775843927359]