Claude Code Boilerplate
FeaturesPricingBlogDocs
Get started →

Product

  • Features
  • Pricing
  • Skills

Compare

  • vs ShipFast
  • vs MakerKit
  • vs supastarter

Resources

  • Docs
  • Blog
  • Discord

Legal

  • License
  • Privacy Policy
  • Terms of Service
Claude Code Boilerplate

© 2026 Claude Code Boilerplate. All rights reserved.

← All posts

Two-factor authentication in Next.js App Router -- TOTP setup, QR codes, and backup codes with Drizzle ORM

June 22, 2026
nextjsdrizzle-ormauthsaassecurity

Two-factor authentication in Next.js App Router -- TOTP setup, QR codes, and backup codes with Drizzle ORM

Your B2B customers are going to ask for it. Enterprises put it on their security questionnaire. And once a user account gets compromised, you will wish you had built it sooner.

Two-factor authentication (2FA) with a time-based one-time password (TOTP) -- the kind that works with Google Authenticator or Authy -- is the right default for a SaaS. Here is how to add it to a Next.js App Router project using this boilerplate and Drizzle ORM.

What you are building

  • A setup flow: user scans a QR code, confirms a 6-digit code, and 2FA is enabled on their account
  • A login check: after password, the server asks for the TOTP code before issuing a JWT
  • Backup codes: a set of one-time codes the user can print and store in case they lose their phone

Schema changes

Add three columns to your userTable:

// modules/user/user.schema.ts
totpSecret: text("totp_secret"),
totpEnabled: boolean("totp_enabled").notNull().default(false),
backupCodes: text("backup_codes").array().notNull().default([]),

Run npm run db:generate && npm run db:migrate to apply them.

Installing dependencies

npm install otpauth qrcode
npm install --save-dev @types/qrcode

otpauth handles secret generation and TOTP verification. qrcode turns the otpauth:// URI into a data URL you can render as an <img>.

Setting up 2FA -- the service layer

// modules/user/user.service.ts
import * as OTPAuth from "otpauth";
import QRCode from "qrcode";
import crypto from "crypto";
 
export async function generateTotpSetup(userId: string) {
  const secret = new OTPAuth.Secret({ size: 20 });
  const user = await userRepo.findById(userId);
 
  const totp = new OTPAuth.TOTP({
    issuer: "YourApp",
    label: user.email,
    algorithm: "SHA1",
    digits: 6,
    period: 30,
    secret,
  });
 
  const uri = totp.toString();
  const qrDataUrl = await QRCode.toDataURL(uri);
 
  // Store the secret -- not enabled yet
  await userRepo.update(userId, { totpSecret: secret.base32 });
 
  return { qrDataUrl, secret: secret.base32 };
}
 
export async function verifyAndEnableTotp(userId: string, code: string) {
  const user = await userRepo.findById(userId);
  if (!user.totpSecret) throw new HttpError(400, "TOTP setup not started");
 
  const totp = new OTPAuth.TOTP({
    secret: OTPAuth.Secret.fromBase32(user.totpSecret),
    digits: 6,
    period: 30,
  });
 
  const delta = totp.validate({ token: code, window: 1 });
  if (delta === null) throw new HttpError(400, "Invalid code");
 
  const backupCodes = Array.from({ length: 8 }, () =>
    crypto.randomBytes(5).toString("hex")
  );
 
  await userRepo.update(userId, { totpEnabled: true, backupCodes });
 
  return { backupCodes };
}

The secret is stored immediately on setup start so the QR code is stable across page refreshes. totpEnabled only flips to true after the user confirms a valid code.

Login flow check

After the password check passes in your existing login service, add this:

if (user.totpEnabled) {
  // Issue a short-lived "needs_2fa" token instead of the real JWT
  return { needs2fa: true, tempToken: signTempToken(user.id) };
}

The temp token scopes the session to 2FA completion only. Your /api/auth/verify-totp route validates it, checks the TOTP code, then issues the real session JWT.

Backup code redemption

export async function redeemBackupCode(userId: string, code: string) {
  const user = await userRepo.findById(userId);
  const index = user.backupCodes.indexOf(code);
  if (index === -1) throw new HttpError(400, "Invalid backup code");
 
  // Remove the used code -- each backup code is single-use
  const remaining = user.backupCodes.filter((_, i) => i !== index);
  await userRepo.update(userId, { backupCodes: remaining });
}

Each code is removed on use. When the user runs out, they need to re-enroll or contact support.

What the UI looks like

The setup flow is three steps:

  1. User clicks "Enable 2FA" in settings -- your route calls generateTotpSetup and returns the QR data URL
  2. User scans it with their authenticator app and submits the 6-digit code
  3. Server calls verifyAndEnableTotp -- on success, display the backup codes once (never again)

The backup codes screen is the only time you show them. Tell users to save them. After that, they are stored as plaintext in the DB -- that is acceptable since they are single-use and only useful alongside a valid password. Hash them with bcrypt if your security posture requires it.

How long this takes

Starting from scratch you are looking at a full day just researching the TOTP spec, wiring the schema, and building the UI flows.

With the DDD-lite module pattern already in place, the breakdown looks like this:

  • Schema change: 5 minutes
  • Service layer: 30 minutes
  • UI flow: 1 hour
  • Login integration: 30 minutes

About 2 hours from zero to a working 2FA system that satisfies enterprise security questionnaires.

Clone this boilerplate -- auth, database, email, and payments are already wired in so you spend those 2 hours on 2FA instead of rebuilding what every SaaS needs before the first feature ships.