BoltbaseGet Boltbase - $99

Setting Up 2FA with NextAuth v5 and TOTP: A Practical Guide

P
Przemyslaw Idzczak
Founder, Boltbase

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 otpauth and qrcode npm 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) in totp.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.