Step-by-step tutorial to add two-factor authentication (TOTP) to a Next.js app using the Better Auth plugin.
Adding 2FA (two-factor authentication) to a Next.js app used to be painful. In 2026, with Better Auth and its TOTP plugin, it's 30 well-organized minutes.
This guide assumes you already have Better Auth working. If not, start with their quickstart and come back here.
bun add better-authYou already have it installed, we just activate the plugin.
src/lib/auth/server.ts:
import { betterAuth } from 'better-auth';
import { twoFactor } from 'better-auth/plugins';
import { prismaAdapter } from 'better-auth/adapters/prisma';
import { db } from '@/lib/db/client';
export const auth = betterAuth({
database: prismaAdapter(db, { provider: 'postgresql' }),
emailAndPassword: { enabled: true },
plugins: [
twoFactor({
issuer: 'CREA.MBA',
}),
],
});Better Auth needs additional tables for 2FA. In your schema.prisma:
model TwoFactor {
id String @id
secret String
backupCodes String
userId String
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
}Then:
bunx prisma migrate dev --name add-2faWhen the user enables 2FA, you generate a TOTP secret and return a QR for them to scan.
src/app/api/auth/2fa/enable/route.ts:
import { auth } from '@/lib/auth/server';
import { headers } from 'next/headers';
import { NextResponse } from 'next/server';
export async function POST() {
const session = await auth.api.getSession({ headers: await headers() });
if (!session) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
}
const result = await auth.api.enableTwoFactor({
body: { password: '' }, // ask for password in production
headers: await headers(),
});
return NextResponse.json({
qrCode: result.totpURI,
backupCodes: result.backupCodes,
});
}'use client';
import { useState } from 'react';
import QRCode from 'react-qr-code';
export function Enable2FA() {
const [qrCode, setQrCode] = useState<string | null>(null);
const [backupCodes, setBackupCodes] = useState<string[]>([]);
const [verificationCode, setVerificationCode] = useState('');
async function startSetup() {
const res = await fetch('/api/auth/2fa/enable', { method: 'POST' });
const data = await res.json();
setQrCode(data.qrCode);
setBackupCodes(data.backupCodes);
}
async function verifyAndActivate() {
const res = await fetch('/api/auth/2fa/verify', {
method: 'POST',
body: JSON.stringify({ code: verificationCode }),
headers: { 'Content-Type': 'application/json' },
});
if (res.ok) alert('2FA enabled');
}
if (!qrCode) {
return <button onClick={startSetup}>Enable 2FA</button>;
}
return (
<div>
<p>Scan with your authenticator app:</p>
<QRCode value={qrCode} />
<p>Backup codes (save these):</p>
<ul>
{backupCodes.map((c) => (
<li key={c}>
<code>{c}</code>
</li>
))}
</ul>
<input
value={verificationCode}
onChange={(e) => setVerificationCode(e.target.value)}
placeholder="6-digit code"
/>
<button onClick={verifyAndActivate}>Verify</button>
</div>
);
}On your login screen, after validating email + password, check if the user has 2FA enabled. If they do, ask for the TOTP code before creating the session.
async function handleLogin(email: string, password: string) {
const res = await auth.signIn.email({ email, password });
if (res.requiresTwoFactor) {
setShow2FAInput(true);
return;
}
// full login
}
async function handle2FACode(code: string) {
const res = await auth.twoFactor.verifyTotp({ code });
if (res.success) {
// full login
}
}If the user loses their phone, backup codes save lives. Better Auth generates them automatically. When one is used, it's invalidated (not reusable).
Your UI should let the user regenerate them whenever they want. It's good practice to regenerate them every 6-12 months.
1. The code is invalid: the server's clock and the phone's clock must be in sync. If your server drifts more than 30 seconds, TOTP codes fail.
2. QR doesn't scan: many apps prefer otpauth://totp/... rather than a raw URL. Better Auth
already provides it in the right format.
3. Backup codes in plain text: never store backup codes in plain text. Better Auth hashes them by default.
30 minutes for properly done 2FA. The longest part is usually the setup UI; the Better Auth plugin logic is practically plug-and-play.
There's no excuse not to have it today. And your B2B users will notice.
Subscribe for more tutorials and tips on building products with AI
The actual integration of Tailwind v4 + shadcn 2.3.0 in Next.js 16 App Router. OKLCH, FOUC-free dark mode, Geist with next/font, and the silent hsl(var()) bug that breaks your styles without warning.