// BLOG: POST
Back
DEEPAK NEGI

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.

20 May 2026
CONTENT

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/qrcode

Database 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 push

Note: Use db push rather than migrate dev if 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:

EndpointPurpose
POST /api/auth/two-factor/enableStart enable flow, returns TOTP URI + backup codes
POST /api/auth/two-factor/verify-totpVerify a TOTP code (setup or sign-in)
POST /api/auth/two-factor/verify-backup-codeVerify a one-time backup code
POST /api/auth/two-factor/disableDisable 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 loads dist/client.js, not src/client.ts. A stale build means twoFactorClient is 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-auth and qrcode.react
  • Add TwoFactor model to Prisma schema and push
  • Register twoFactor({ issuer }) plugin on the server
  • Register twoFactorClient() plugin on the client and rebuild dist
  • Check twoFactorRedirect in 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
All Posts© 2026 Deepak Negi