Setting Up 2FA with NextAuth v5 and TOTP: A Practical Guide
Two-factor authentication (2FA) is one of the most effective ways to protect your users' accounts. Despite that, most Next.js boilerplates ship without it, leaving developers to figure out the implementation on their own. This guide walks through adding TOTP-based 2FA to a Next.js app using NextAuth v5.
What is TOTP?
TOTP stands for Time-based One-Time Password, the standard used by apps like Google Authenticator, Authy, and 1Password. The user scans a QR code once to pair their authenticator app, then enters a 6-digit code that rotates every 30 seconds. Unlike SMS codes, TOTP works offline and isn't vulnerable to SIM swapping.
Prerequisites
You need:
- A Next.js 16 app with the App Router
- NextAuth v5 (
next-auth@5) installed and configured - A database to persist user records (this guide uses PostgreSQL + Drizzle)
- The
otpauthandqrcodenpm packages
Install the dependencies:
bun add otpauth qrcode
bun add -D @types/qrcode
Step 1: Extend the User Schema
Add a twoFactorSecret field and a twoFactorEnabled flag to your users table.
// db/schema.ts
import { pgTable, text, boolean } from "drizzle-orm/pg-core";
export const users = pgTable("users", {
id: text("id").primaryKey(),
email: text("email").notNull().unique(),
twoFactorSecret: text("two_factor_secret"),
twoFactorEnabled: boolean("two_factor_enabled").notNull().default(false),
// ... other fields
});
Run a migration to apply the schema change:
bun drizzle-kit generate
bun drizzle-kit migrate
Step 2: Generate a TOTP Secret
When a user wants to enable 2FA, generate a secret for them and return the provisioning URI to display as a QR code.
// app/api/2fa/setup/route.ts
import { TOTP } from "otpauth";
import QRCode from "qrcode";
import { auth } from "@/auth";
import { db } from "@/db";
import { users } from "@/db/schema";
import { eq } from "drizzle-orm";
export async function POST() {
const session = await auth();
if (!session?.user?.id) {
return Response.json({ error: "Unauthorized" }, { status: 401 });
}
const totp = new TOTP({
issuer: "YourApp",
label: session.user.email ?? session.user.id,
algorithm: "SHA1",
digits: 6,
period: 30,
});
const secret = totp.secret.base32;
const uri = totp.toString();
// Store secret temporarily — only persist once user confirms
await db
.update(users)
.set({ twoFactorSecret: secret })
.where(eq(users.id, session.user.id));
const qrCodeDataUrl = await QRCode.toDataURL(uri);
return Response.json({ qrCode: qrCodeDataUrl, secret });
}
Step 3: Verify the Code and Enable 2FA
After the user scans the QR code, ask them to enter the current 6-digit code to confirm pairing.
// app/api/2fa/verify/route.ts
import { TOTP, Secret } from "otpauth";
import { auth } from "@/auth";
import { db } from "@/db";
import { users } from "@/db/schema";
import { eq } from "drizzle-orm";
export async function POST(request: Request) {
const session = await auth();
if (!session?.user?.id) {
return Response.json({ error: "Unauthorized" }, { status: 401 });
}
const { code } = await request.json() as { code: string };
const [user] = await db
.select()
.from(users)
.where(eq(users.id, session.user.id));
if (!user?.twoFactorSecret) {
return Response.json({ error: "No secret found" }, { status: 400 });
}
const totp = new TOTP({
secret: Secret.fromBase32(user.twoFactorSecret),
algorithm: "SHA1",
digits: 6,
period: 30,
});
const delta = totp.validate({ token: code, window: 1 });
if (delta === null) {
return Response.json({ error: "Invalid code" }, { status: 400 });
}
await db
.update(users)
.set({ twoFactorEnabled: true })
.where(eq(users.id, session.user.id));
return Response.json({ success: true });
}
Step 4: Enforce 2FA at Sign-In
The tricky part with NextAuth v5 is enforcing 2FA after the credentials check but before issuing the session. The cleanest way is to use a custom sign-in flow with an intermediate step.
// auth.ts
import NextAuth from "next-auth";
import Credentials from "next-auth/providers/credentials";
import { TOTP, Secret } from "otpauth";
import { db } from "@/db";
import { users } from "@/db/schema";
import { eq } from "drizzle-orm";
import { verifyPassword } from "@/lib/password";
export const { handlers, auth, signIn, signOut } = NextAuth({
providers: [
Credentials({
credentials: {
email: { type: "email" },
password: { type: "password" },
totpCode: { type: "text" },
},
async authorize(credentials) {
const [user] = await db
.select()
.from(users)
.where(eq(users.email, credentials.email as string));
if (!user) return null;
const passwordValid = await verifyPassword(
credentials.password as string,
user.passwordHash
);
if (!passwordValid) return null;
if (user.twoFactorEnabled && user.twoFactorSecret) {
const code = credentials.totpCode as string;
if (!code) return null;
const totp = new TOTP({
secret: Secret.fromBase32(user.twoFactorSecret),
algorithm: "SHA1",
digits: 6,
period: 30,
});
const valid = totp.validate({ token: code, window: 1 }) !== null;
if (!valid) return null;
}
return { id: user.id, email: user.email };
},
}),
],
});
Step 5: The Sign-In UI
Your sign-in form needs to handle the case where a user has 2FA enabled. The cleanest UX is a two-step form: first email + password, then — if the server signals that 2FA is required — show a second screen for the TOTP code.
One pattern is to return a specific error from authorize when the password is valid but no TOTP code was provided, then display the TOTP field only when that error appears.
// app/login/LoginForm.tsx
"use client";
import { useState } from "react";
import { signIn } from "next-auth/react";
export function LoginForm() {
const [step, setStep] = useState<"credentials" | "totp">("credentials");
const [email, setEmail] = useState("");
const [password, setPassword] = useState("");
const [totpCode, setTotpCode] = useState("");
const [error, setError] = useState("");
async function handleSubmit(e: React.FormEvent) {
e.preventDefault();
setError("");
const result = await signIn("credentials", {
email,
password,
totpCode: step === "totp" ? totpCode : "",
redirect: false,
});
if (result?.error === "TOTP_REQUIRED") {
setStep("totp");
} else if (result?.error) {
setError("Invalid credentials or code.");
} else {
window.location.href = "/dashboard";
}
}
return (
<form onSubmit={handleSubmit}>
{step === "credentials" && (
<>
<input type="email" value={email} onChange={(e) => setEmail(e.target.value)} />
<input type="password" value={password} onChange={(e) => setPassword(e.target.value)} />
</>
)}
{step === "totp" && (
<input
type="text"
inputMode="numeric"
maxLength={6}
placeholder="6-digit code"
value={totpCode}
onChange={(e) => setTotpCode(e.target.value)}
/>
)}
{error && <p>{error}</p>}
<button type="submit">
{step === "credentials" ? "Continue" : "Verify"}
</button>
</form>
);
}
Backup Codes
Always generate backup codes when a user enables 2FA. If they lose access to their authenticator app, backup codes are the only recovery path. Generate 8-10 random codes, hash them (bcrypt), and store them alongside the user record. Let the user download or copy them once — then only store the hashes.
UX Considerations
- Show a recovery code download step immediately after enabling 2FA.
- Let users disable 2FA from account settings, gated by a current TOTP code or backup code.
- Rate-limit the TOTP verification endpoint — 5 attempts per minute is a reasonable limit.
- Use
window: 1(±30 seconds) intotp.validate()to accommodate minor clock drift.
Wrapping Up
TOTP-based 2FA is a one-time investment that significantly raises the security bar for your users. The implementation is well within reach for any Next.js app, and libraries like otpauth handle all the cryptographic heavy lifting. Once you add it, you'll wonder why it wasn't there from the start.
Boltbase ships with this exact 2FA flow pre-built — including backup codes, the two-step sign-in UI, and account recovery — so you don't have to build any of this yourself.