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

How to Add Two-Factor Authentication to Your Next.js SaaS -- TOTP and Backup Codes Without Auth0

October 7, 2026
nextjsauthsaassecuritydrizzle-orm

Two-factor authentication (2FA) does not show up on a founder's roadmap until an enterprise prospect sends a security questionnaire. Then it becomes blocking.

If you have already wired up JWT auth in your Next.js SaaS boilerplate, adding TOTP-based 2FA is a one-afternoon extension -- not a replacement. Here is what the flow looks like and how each piece fits into what you already have.

What TOTP Is (and What It Is Not)

TOTP stands for Time-based One-Time Password. It is the "enter the 6-digit code from your authenticator app" step you see in most serious web products. The user scans a QR code once with an app like Google Authenticator, Authy, or 1Password. After that, the app generates a new 6-digit code every 30 seconds. Your server and the user's app share a secret -- if the code matches, you know it is really them.

It is not SMS verification (a different protocol, and weaker), not hardware tokens (overkill for most SaaS products), and not Auth0 or any external identity provider. You generate the TOTP secret yourself, store it in your database, and verify codes entirely server-side.

The Two-Phase Flow

2FA has two distinct phases: setup and enforcement.

Setup (user opts in, or your app requires it):

  1. User goes to their security settings
  2. Your server generates a TOTP secret and stores it (unverified) in the database
  3. You encode the secret as a otpauth:// URI and render a QR code in the UI
  4. The user scans it with their authenticator app and enters the 6-digit code
  5. Your server verifies the code -- if it matches, flip totp_enabled to true

Enforcement (every login after setup):

  1. User submits email and password as normal
  2. Your login route checks whether totp_enabled is true for this account
  3. If it is, return a short-lived "pending MFA" JWT (5-minute expiry) instead of a full session token
  4. The UI redirects to a "enter your code" screen
  5. User enters their 6-digit TOTP code
  6. Your server verifies it, then issues a full session JWT with mfaVerified: true

The pending token pattern keeps your existing login route intact. Nothing upstream changes -- you add one check after the password step.

Database Changes

You need three new columns on your users table:

totp_secret   text      -- the TOTP seed (encrypt at rest)
totp_enabled  boolean   -- default false; true once user verifies the first code
backup_codes  text[]    -- hashed one-time recovery codes

In Drizzle, add those to your existing user schema, then run:

npm run db:generate
npm run db:migrate

No existing columns change. The migration is additive.

Backup Codes -- Do Not Skip Them

If a user loses their phone, a 6-digit code requirement will lock them out permanently. Backup codes solve this.

Generate 8 to 10 random codes (10 characters each) when the user enables 2FA. Show them once, in plaintext, with a clear instruction to save them offline. Store only the bcrypt hashes -- the same bcryptjs already in the boilerplate for passwords. When a user enters a backup code instead of a TOTP code, delete that hash from the array so it cannot be reused.

Eight codes gives the user enough margin to recover without feeling like they are constantly burning through them.

Fitting It Into Existing JWT Auth

The boilerplate issues JWTs via lib/auth.ts and reads them with getUserFromRequest(). Two-factor authentication adds one claim to the token payload:

{ userId: string, mfaVerified: boolean }

Routes that require 2FA check mfaVerified: true before proceeding. Routes that do not care -- public endpoints, the first login step -- ignore the claim. The pending MFA token has a short expiry and mfaVerified: false. It can only reach one endpoint: POST /api/auth/mfa/verify, which validates the TOTP code and returns a full-session JWT.

No existing route changes. You add one guard where it matters.

The Libraries You Need

otplib handles TOTP generation and verification. It is well-maintained, covers the 30-second time window and clock drift, and the relevant API is three lines:

import { authenticator } from 'otplib'
 
// generate a secret during setup
const secret = authenticator.generateSecret()
 
// verify a code the user submits
const isValid = authenticator.verify({ token: code, secret })

qrcode renders the otpauth:// URI as a PNG or SVG that any authenticator app can scan:

import QRCode from 'qrcode'
const uri = authenticator.keyuri(user.email, 'YourApp', secret)
const qrDataUrl = await QRCode.toDataURL(uri)

Both packages are small. Neither requires an account, an API key, or a paid tier.

When Enterprise Buyers Ask

Enterprise security questionnaires ask about 2FA alongside the other items covered in the Next.js SaaS security checklist. Most early-stage SaaS products skip it and then scramble to add it when a deal depends on it.

The good news is that the JWT pattern already in the boilerplate is the right foundation for TOTP. You are not replacing auth -- you are extending it. Two database columns, two npm packages, one new API endpoint, and one UI screen for setup.

If you are building a product that will eventually sell to teams or businesses, add 2FA before your first enterprise conversation. It is faster to build now than to unblock a deal under deadline.

Get the Next.js SaaS Boilerplate and ship 2FA this week -- before the enterprise prospect who needs it lands in your inbox.