Use Cases (Updated: 6/1/2026)

Implement 2FA with Claude Code: TOTP, Backup Codes, Recovery, and Step-Up Auth

Build safer 2FA/MFA in Next.js with Claude Code: TOTP, backup codes, recovery, rate limits, passkeys, and audit logs.

Implement 2FA with Claude Code: TOTP, Backup Codes, Recovery, and Step-Up Auth

Two-factor authentication is not just a QR code screen. A production 2FA/MFA design must handle TOTP, backup codes, step-up authentication for sensitive actions, recovery, rate limits, remembered devices, audit logs, and a path toward phishing-resistant WebAuthn/passkeys.

This guide shows how I would make Claude Code implement 2FA in a Next.js App Router application with TypeScript and Prisma. Pair it with the Claude Code JWT authentication guide, password reset guide, role-based access guide, and Claude Code security best practices. The external baseline comes from OWASP MFA Cheat Sheet, OWASP WSTG MFA testing, MDN Web Authentication API, and W3C WebAuthn Level 3.

The Security Boundary

TOTP means time-based one-time password. The server and authenticator app share a secret and generate a short code every 30 seconds. MFA raises the assurance of a session, but it does not replace authorization. Admin invites, billing changes, API key creation, password changes, and exporting personal data should still require step-up auth, meaning the user must complete a fresh MFA challenge before the action.

flowchart TD
  A["Password or SSO login"] --> B{"2FA enabled?"}
  B -->|No| C["Lower-assurance session"]
  B -->|Yes| D["TOTP or backup code challenge"]
  D --> E{"Valid and rate limit OK?"}
  E -->|No| F["Deny and audit"]
  E -->|Yes| G["Session with mfaAt"]
  G --> H{"Sensitive action?"}
  H -->|Yes| I{"mfaAt fresh?"}
  I -->|No| D
  I -->|Yes| J["Allow"]
  H -->|No| J

Give Claude Code this boundary first. Do not ask for “add 2FA” as one large task. Split the work into database schema, cryptographic helpers, setup API, enable API, login challenge, recovery, remembered devices, audit logs, and tests. Keep production secrets, KMS settings, and support-policy decisions under human control.

Prompt and Review Checklist

Use a narrow prompt:

Implement 2FA for a Next.js App Router app with TypeScript and Prisma.
Scope: TOTP, backup codes, remembered devices, step-up auth, recovery events, audit logs.
Rules:
- Encrypt TOTP secrets with AES-256-GCM before saving.
- Hash backup codes and remembered device tokens with HMAC-SHA256.
- Apply rate limits to setup, enable, login challenge, and recovery.
- Do not make SMS the primary factor.
- Keep a migration path to WebAuthn/passkeys.
First output the Prisma schema and review checklist only.

During review, look for these failures: TOTP secret stored as plaintext, backup codes stored or logged as plaintext, no usedAt on backup codes, no rate limit on the 6-digit challenge, remember-device tokens stored raw, MFA reset controlled by one support operator, and no audit log for enable, disable, failed challenge, recovery, or factor change.

Data Model

This schema stores encrypted TOTP material, one-time backup codes, remembered devices, and security audit events.

// prisma/schema.prisma
model User {
  id                         String              @id @default(cuid())
  email                      String              @unique
  passwordHash               String
  twoFactorEnabled           Boolean             @default(false)
  twoFactorEnabledAt         DateTime?
  twoFactorSecretCiphertext  String?
  twoFactorSecretIv          String?
  twoFactorSecretTag         String?
  backupCodes                BackupCode[]
  rememberedDevices          RememberedDevice[]
  auditLogs                  AuditLog[]
  createdAt                  DateTime            @default(now())
  updatedAt                  DateTime            @updatedAt
}

model BackupCode {
  id        String    @id @default(cuid())
  userId    String
  codeHash  String
  usedAt    DateTime?
  createdAt DateTime  @default(now())
  user      User      @relation(fields: [userId], references: [id], onDelete: Cascade)

  @@index([userId, usedAt])
  @@unique([userId, codeHash])
}

model RememberedDevice {
  id          String   @id @default(cuid())
  userId      String
  deviceHash  String
  userAgent   String?
  expiresAt   DateTime
  lastUsedAt  DateTime?
  createdAt   DateTime @default(now())
  user        User     @relation(fields: [userId], references: [id], onDelete: Cascade)

  @@index([userId, expiresAt])
  @@unique([userId, deviceHash])
}

model AuditLog {
  id        String   @id @default(cuid())
  userId    String?
  action    String
  metadata  Json?
  ipAddress String?
  userAgent String?
  createdAt DateTime @default(now())
  user      User?    @relation(fields: [userId], references: [id], onDelete: SetNull)

  @@index([userId, createdAt])
  @@index([action, createdAt])
}
# .env.example
DATABASE_URL="postgresql://user:password@localhost:5432/app"
TWO_FACTOR_ENCRYPTION_KEY="base64-encoded-32-byte-key"
TWO_FACTOR_HASH_SECRET="long-random-hmac-secret"

Crypto and Rate Limit Helpers

The TOTP secret is encrypted because the server must read it later. Backup codes and remembered device tokens are hashed because the server only needs to compare them.

// src/lib/two-factor.ts
import { createCipheriv, createDecipheriv, createHmac, randomBytes, timingSafeEqual } from "node:crypto";
import { authenticator } from "otplib";

authenticator.options = { step: 30, window: 1 };

const buckets = new Map<string, { count: number; resetAt: number }>();

function encryptionKey() {
  const key = Buffer.from(process.env.TWO_FACTOR_ENCRYPTION_KEY ?? "", "base64");
  if (key.length !== 32) throw new Error("TWO_FACTOR_ENCRYPTION_KEY must be 32 bytes in base64.");
  return key;
}

function hmacSecret() {
  const secret = process.env.TWO_FACTOR_HASH_SECRET;
  if (!secret || secret.length < 32) throw new Error("TWO_FACTOR_HASH_SECRET is too short.");
  return secret;
}

export function encryptSecret(secret: string) {
  const iv = randomBytes(12);
  const cipher = createCipheriv("aes-256-gcm", encryptionKey(), iv);
  const ciphertext = Buffer.concat([cipher.update(secret, "utf8"), cipher.final()]);
  return { ciphertext: ciphertext.toString("base64"), iv: iv.toString("base64"), tag: cipher.getAuthTag().toString("base64") };
}

export function decryptSecret(input: { ciphertext: string; iv: string; tag: string }) {
  const decipher = createDecipheriv("aes-256-gcm", encryptionKey(), Buffer.from(input.iv, "base64"));
  decipher.setAuthTag(Buffer.from(input.tag, "base64"));
  return Buffer.concat([decipher.update(Buffer.from(input.ciphertext, "base64")), decipher.final()]).toString("utf8");
}

export function verifyTotp(token: string, secret: string) {
  return /^\d{6}$/.test(token) && authenticator.verify({ token, secret });
}

export function generateBackupCodes(count = 10) {
  return Array.from({ length: count }, () => {
    const raw = randomBytes(5).toString("hex").toUpperCase();
    return `${raw.slice(0, 5)}-${raw.slice(5)}`;
  });
}

export function hashBackupCode(userId: string, code: string) {
  const normalized = code.replace(/[^a-zA-Z0-9]/g, "").toUpperCase();
  return createHmac("sha256", hmacSecret()).update(`backup:${userId}:${normalized}`).digest("hex");
}

export function hashRememberedDevice(userId: string, token: string) {
  return createHmac("sha256", hmacSecret()).update(`remember:${userId}:${token}`).digest("hex");
}

export function safeEqualHex(a: string, b: string) {
  const left = Buffer.from(a, "hex");
  const right = Buffer.from(b, "hex");
  return left.length === right.length && timingSafeEqual(left, right);
}

export function consumeRateLimit(key: string, limit: number, windowMs: number) {
  const now = Date.now();
  const bucket = buckets.get(key);
  if (!bucket || bucket.resetAt <= now) {
    buckets.set(key, { count: 1, resetAt: now + windowMs });
    return true;
  }
  bucket.count += 1;
  return bucket.count <= limit;
}

The in-memory limiter is acceptable for local development and tests. In production, ask Claude Code to replace the Map with Redis or Upstash while keeping the same function signature.

Setup and Enable Routes

The setup route creates a secret and QR code but does not enable 2FA. Enablement happens only after the first TOTP code is verified.

// src/app/api/account/2fa/enable/route.ts
import { NextRequest, NextResponse } from "next/server";
import { z } from "zod";
import { prisma } from "@/lib/prisma";
import { requireUser } from "@/lib/require-user";
import { decryptSecret, generateBackupCodes, hashBackupCode, consumeRateLimit, verifyTotp } from "@/lib/two-factor";

const schema = z.object({ token: z.string().regex(/^\d{6}$/) });

export async function POST(request: NextRequest) {
  const user = await requireUser();
  const { token } = schema.parse(await request.json());

  if (!consumeRateLimit(`2fa-enable:${user.id}`, 5, 10 * 60 * 1000)) {
    return NextResponse.json({ error: "Too many attempts." }, { status: 429 });
  }

  const record = await prisma.user.findUniqueOrThrow({ where: { id: user.id } });
  if (!record.twoFactorSecretCiphertext || !record.twoFactorSecretIv || !record.twoFactorSecretTag) {
    return NextResponse.json({ error: "2FA setup has not started." }, { status: 400 });
  }

  const secret = decryptSecret({
    ciphertext: record.twoFactorSecretCiphertext,
    iv: record.twoFactorSecretIv,
    tag: record.twoFactorSecretTag,
  });

  if (!verifyTotp(token, secret)) {
    await prisma.auditLog.create({ data: { userId: user.id, action: "2fa.enable.failed" } });
    return NextResponse.json({ error: "Invalid code." }, { status: 400 });
  }

  const backupCodes = generateBackupCodes();
  await prisma.$transaction([
    prisma.backupCode.deleteMany({ where: { userId: user.id } }),
    prisma.user.update({ where: { id: user.id }, data: { twoFactorEnabled: true, twoFactorEnabledAt: new Date() } }),
    prisma.backupCode.createMany({ data: backupCodes.map((code) => ({ userId: user.id, codeHash: hashBackupCode(user.id, code) })) }),
    prisma.auditLog.create({ data: { userId: user.id, action: "2fa.enabled", metadata: { backupCodeCount: backupCodes.length } } }),
  ]);

  return NextResponse.json({ backupCodes });
}

The login challenge should accept either a TOTP code or an unused backup code. On backup-code success, mark the code as used in the same request. For remembered devices, create a random token, store only its HMAC hash, and send the raw token in a secure, httpOnly, sameSite cookie with a short expiration such as 30 days.

UI Component

// src/components/TwoFactorSetupPanel.tsx
"use client";

import { useState } from "react";

export function TwoFactorSetupPanel() {
  const [qrCodeDataUrl, setQrCodeDataUrl] = useState("");
  const [manualKey, setManualKey] = useState("");
  const [token, setToken] = useState("");
  const [backupCodes, setBackupCodes] = useState<string[]>([]);
  const [error, setError] = useState("");

  async function startSetup() {
    const response = await fetch("/api/account/2fa/setup", { method: "POST" });
    const data = await response.json();
    if (!response.ok) return setError(data.error ?? "Setup failed.");
    setQrCodeDataUrl(data.qrCodeDataUrl);
    setManualKey(data.manualKey);
  }

  async function enable() {
    const response = await fetch("/api/account/2fa/enable", {
      method: "POST",
      headers: { "Content-Type": "application/json" },
      body: JSON.stringify({ token }),
    });
    const data = await response.json();
    if (!response.ok) return setError(data.error ?? "Invalid code.");
    setBackupCodes(data.backupCodes);
  }

  return (
    <section aria-label="Two-factor authentication">
      {!qrCodeDataUrl && <button onClick={startSetup}>Set up 2FA</button>}
      {qrCodeDataUrl && backupCodes.length === 0 && (
        <>
          <img src={qrCodeDataUrl} alt="Authenticator app QR code" width={220} height={220} />
          <p>Manual key: <code>{manualKey}</code></p>
          <input inputMode="numeric" autoComplete="one-time-code" value={token} onChange={(event) => setToken(event.target.value)} maxLength={6} />
          <button onClick={enable}>Verify and enable</button>
        </>
      )}
      {backupCodes.length > 0 && (
        <ul>{backupCodes.map((code) => <li key={code}><code>{code}</code></li>)}</ul>
      )}
      {error && <p role="alert">{error}</p>}
    </section>
  );
}

Recovery, SMS, and Passkeys

Recovery is often the weakest part of MFA. Prefer backup codes first. If the user has lost every factor, require multiple signals: email confirmation, an existing trusted session if available, organization-owner approval for B2B, and a cooldown before high-risk actions. Notify the user after recovery and revoke old sessions.

SMS is better than no second factor in some consumer flows, but it is exposed to SIM swap, phone-number recycling, and telecom support abuse. Treat it as a transitional or recovery option, not the primary factor for administrators or financial actions.

Passkeys and WebAuthn are the long-term direction because they use public-key credentials bound to the origin. Start with a schema that can add WebAuthn credentials later, then ask Claude Code to implement passkey registration under the same audit, revocation, and step-up rules.

// src/lib/step-up.ts
export function assertFreshMfa(session: { mfaAt?: Date | null }, maxAgeMinutes = 15) {
  if (!session.mfaAt) throw new Error("MFA is required.");
  if (Date.now() - session.mfaAt.getTime() > maxAgeMinutes * 60 * 1000) {
    throw new Error("MFA challenge is too old.");
  }
}

Use Cases and Pitfalls

Use case 1: admin invitations. Require fresh MFA before inviting an owner or administrator. A stolen session should not be enough to take over the organization.

Use case 2: billing changes. Require step-up auth for card changes, invoice email changes, refunds, and tax data edits. Audit the action without logging card data or backup codes.

Use case 3: API key creation. Require MFA before creating or expanding API keys, and combine it with RBAC so only users with the right permission can pass the flow.

Use case 4: support recovery. A support agent should create a recovery request, not directly disable MFA. Add approval, user notification, cooldown, and audit logs.

Common pitfalls are plaintext TOTP secrets, reusable backup codes, no rate limit on 6-digit challenges, remember-device raw tokens stored in the database, MFA disablement without step-up auth, recovery links with long lifetimes, and audit logs that accidentally contain secrets.

Closing

A serious 2FA implementation is a session-assurance project, not a single form. With Claude Code, the winning approach is to keep each task small, insist on encryption and hashing boundaries, review rate limits and recovery flows, and leave room for passkeys. ClaudeCodeLab can help review an existing MFA flow, train teams on Claude Code security workflows, or implement TOTP-to-passkey roadmaps for Next.js products.

When trying this implementation, verify that encryption keys differ between environments, backup codes are shown once, failed TOTP attempts are rate limited, remembered-device cookies are httpOnly and revocable, MFA disablement requires step-up auth, and audit logs contain events rather than secrets.

#Claude Code #2FA #MFA #TOTP #security
Free

Free PDF: Claude Code Cheatsheet

Enter your email and download the one-page Claude Code cheatsheet for commands, review habits, and safe workflows.

We handle your data with care and never send spam.

Level up your Claude Code workflow

Start with the free PDF, use Gumroad guides when you need repeatable workflows, and book consultation when rollout or revenue paths need human judgment.

Masa

About the Author

Masa

Engineer focused on practical Claude Code workflows. Runs claudecode-lab.com, a 10-language technical media site.