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.
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.
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>.
// 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.
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.
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.
The setup flow is three steps:
generateTotpSetup and returns the QR data URLverifyAndEnableTotp -- 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.
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:
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.