A Complete Guide to 2FA in Next.js with Better-Auth
Why two-factor authentication matters and how to implement TOTP-based 2FA end-to-end in a Next.js App Router project using Better-Auth v1.5.
Two-factor authentication (2FA) is one of the highest-impact security measures you can add to a web application. This guide covers why it matters, and walks through a complete implementation using Better-Auth v1.5 in a Next.js App Router project — including TOTP setup, backup codes, and the sign-in verification flow.
Why 2FA Matters
Password-only authentication is a single point of failure. Credentials are leaked in data breaches, reused across services, and guessable through brute force. 2FA adds a second factor — something the user has (their phone) — so that a stolen password alone is not enough to access an account.
Key benefits:
- Breach resilience — even if your database leaks password hashes and they are cracked, attackers cannot log in without the TOTP device.
- Phishing resistance — TOTP codes are time-limited (30-second window), so a phished code is useless seconds later.
- Compliance — SOC 2, HIPAA, and many enterprise procurement requirements mandate MFA for privileged accounts.
How TOTP Works
Time-based One-Time Password (TOTP, RFC 6238) generates a 6-digit code from a shared secret and the current Unix timestamp, floored to 30-second intervals.
TOTP(secret, time) = HOTP(secret, floor(time / 30))
HOTP(secret, counter) = truncate(HMAC-SHA1(secret, counter))The authenticator app (Google Authenticator, Authy, 1Password) stores the secret after scanning a QR code. The server stores the same secret and verifies the code on each sign-in.
Project Setup
This guide uses the following stack:
- Next.js 15 (App Router)
- Better-Auth v1.5 — auth library with first-class TOTP plugin
- Prisma — ORM for PostgreSQL
- qrcode.react — client-side QR code rendering
- Resend — transactional email (for magic links, not covered here)
Install dependencies
bun add better-auth qrcode.react
bun add -d @types/qrcodeDatabase Schema
Better-Auth's twoFactor plugin requires two additions to your Prisma schema: a boolean flag on the user and a separate TwoFactor model to store the TOTP secret and backup codes.
// packages/database/prisma/schema.prisma
model User {
id String @id
email String @unique
name String?
twoFactorEnabled Boolean?
twoFactor TwoFactor?
// ... other fields
}
model TwoFactor {
id String @id
secret String
backupCodes String
userId String @unique
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
@@map("twoFactor")
}Push the schema to the database:
bunx prisma db pushNote: Use
db pushrather thanmigrate devif you have existing data or schema drift that would cause a destructive migration.
Server Configuration
Register the twoFactor plugin in your Better-Auth server instance.
// packages/auth/src/server.ts
import { betterAuth } from "better-auth";
import { prismaAdapter } from "better-auth/adapters/prisma";
import { twoFactor } from "better-auth/plugins";
import { db } from "@repo/database";
export const auth = betterAuth({
database: prismaAdapter(db, { provider: "postgresql" }),
plugins: [
twoFactor({
issuer: "YourAppName", // shown in authenticator apps
}),
],
// ... emailAndPassword, socialProviders, trustedOrigins
});The plugin automatically registers these API endpoints:
| Endpoint | Purpose |
|---|---|
POST /api/auth/two-factor/enable | Start enable flow, returns TOTP URI + backup codes |
POST /api/auth/two-factor/verify-totp | Verify a TOTP code (setup or sign-in) |
POST /api/auth/two-factor/verify-backup-code | Verify a one-time backup code |
POST /api/auth/two-factor/disable | Disable 2FA with password confirmation |
Client Configuration
Add twoFactorClient to your auth client to enable the client-side helpers and intercept the sign-in flow.
// packages/auth/src/client.ts
import { createAuthClient } from "better-auth/react";
import { inferAdditionalFields, twoFactorClient } from "better-auth/client/plugins";
import type { auth } from "./server";
const authClient = createAuthClient({
plugins: [
inferAdditionalFields<typeof auth>(),
twoFactorClient(),
],
});
export const {
signIn,
signUp,
signOut,
useSession,
twoFactor,
} = authClient;Critical: If you compile the auth package to
dist/, rebuild it (tsc) after adding the plugin. The runtime loadsdist/client.js, notsrc/client.ts. A stale build meanstwoFactorClientis not active and sign-in will not trigger the 2FA redirect.
Sign-In Flow with 2FA
With twoFactorClient active, signIn.email() returns { data: { twoFactorRedirect: true } } when the user has 2FA enabled, instead of completing the sign-in. Check for this before the error guard.
// app/(auth)/login/page.tsx (simplified)
const result = await signIn.email({ email, password });
// Check 2FA redirect BEFORE the error guard
if ((result?.data as Record<string, unknown>)?.twoFactorRedirect) {
setView("two-factor"); // switch UI to code entry
return;
}
if (result?.error) {
showErrorToast(result.error.message || "Invalid credentials");
return;
}
// Fully authenticated
router.push("/dashboard");Then render the TOTP code entry:
function TwoFactorStep({ onBack }: { onBack: () => void }) {
const [code, setCode] = useState("");
const [useBackup, setUseBackup] = useState(false);
const [loading, setLoading] = useState(false);
const router = useRouter();
const handleVerify = async () => {
const result = useBackup
? await twoFactor.verifyBackupCode({ code: code.trim() })
: await twoFactor.verifyTotp({ code: code.trim() });
if (result?.error) {
showErrorToast(result.error.message || "Invalid code");
return;
}
router.push("/dashboard");
};
return (
<div>
<input
inputMode="numeric"
maxLength={useBackup ? 16 : 6}
onChange={(e) => setCode(e.target.value)}
onKeyDown={(e) => e.key === "Enter" && handleVerify()}
placeholder={useBackup ? "xxxxxxxx-xxxxxxxx" : "000000"}
value={code}
/>
<button onClick={handleVerify} type="button">Verify</button>
<button onClick={() => setUseBackup((v) => !v)} type="button">
{useBackup ? "Use authenticator code" : "Use backup code"}
</button>
<button onClick={onBack} type="button">Back</button>
</div>
);
}Enable 2FA in Account Settings
The enable flow has three steps: password confirmation → QR code scan → code verification → backup codes.
Step 1 — Confirm password and get the TOTP URI
const result = await twoFactor.enable({ password });
const totpUri = (result?.data as { totpURI?: string })?.totpURI ?? "";
const backupCodes = (result?.data as { backupCodes?: string[] })?.backupCodes ?? [];Step 2 — Render the QR code
Use qrcode.react to render the TOTP URI client-side. Never send the URI to a third-party QR API — it contains the secret.
import { QRCodeSVG } from "qrcode.react";
<QRCodeSVG
bgColor="#ffffff"
fgColor="#1a1a1a"
level="M"
size={180}
value={totpUri}
/>The user scans this with Google Authenticator, Authy, 1Password, or any TOTP app.
Step 3 — Verify the code to confirm setup
const result = await twoFactor.verifyTotp({ code: totpCode });
if (!result?.error) {
setIsEnabled(true);
// show backup codes
}Step 4 — Show backup codes
Better-Auth generates 10 backup codes when you call twoFactor.enable(). Each can be used once if the user loses their authenticator device.
<div className="grid grid-cols-2 gap-2">
{backupCodes.map((code) => (
<code key={code}>{code}</code>
))}
</div>
<button onClick={downloadBackupCodes}>Download</button>Download helper:
const downloadBackupCodes = () => {
const content = [
"YourApp — 2FA Backup Codes",
"Each code can only be used once.",
"",
...backupCodes,
`Generated: ${new Date().toLocaleString()}`,
].join("\n");
const blob = new Blob([content], { type: "text/plain" });
const url = URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = url;
a.download = "backup-codes.txt";
a.click();
URL.revokeObjectURL(url);
};Disable 2FA
Require the user's current password before disabling, so an attacker with a hijacked session cannot silently turn off 2FA.
const result = await twoFactor.disable({ password });
if (!result?.error) {
setIsEnabled(false);
}Reading 2FA Status Server-Side
twoFactorEnabled is added to the user object by the plugin but is not in Better-Auth's static types without explicit server-type binding. Cast to access it safely:
// Server component
const session = await auth.api.getSession({ headers: await headers() });
const twoFactorEnabled =
(session?.user as Record<string, unknown>)?.twoFactorEnabled === true;Handling Social-Only Users
Users who signed up via Google or GitHub have no password. twoFactor.enable({ password }) will fail for them. Gate the enable UI:
// Check if user has a credential (password) provider linked
const hasPassword = linkedProviders?.credential === true;{!hasPassword ? (
<p>
You need a password to enable 2FA. Use the{" "}
<a href="/forgot-password">Forgot Password</a> flow to set one first.
</p>
) : (
<button onClick={startEnable}>Enable 2FA</button>
)}Security Checklist
- TOTP secret stored server-side only — never exposed to the client
- Backup codes hashed in the database (Better-Auth handles this)
- Password required to enable and disable 2FA
- Rate-limit TOTP verification attempts (Better-Auth has built-in rate limiting)
- TOTP codes have a ±1 window tolerance for clock skew
- Backup codes are single-use (Better-Auth enforces this)
- Social-only users cannot enable 2FA without setting a password first
Quick Checklist
- Install
better-authandqrcode.react - Add
TwoFactormodel to Prisma schema and push - Register
twoFactor({ issuer })plugin on the server - Register
twoFactorClient()plugin on the client and rebuild dist - Check
twoFactorRedirectin sign-in handler before the error guard - Render QR code client-side with
qrcode.react - Verify TOTP code with
twoFactor.verifyTotp()to confirm setup - Show and allow download of backup codes after enabling
- Gate enable flow for social-only users (no password)
- Require password to disable 2FA