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.
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.
2FA has two distinct phases: setup and enforcement.
Setup (user opts in, or your app requires it):
otpauth:// URI and render a QR code in the UItotp_enabled to trueEnforcement (every login after setup):
totp_enabled is true for this accountmfaVerified: trueThe pending token pattern keeps your existing login route intact. Nothing upstream changes -- you add one check after the password step.
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.
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.
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.
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.
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.