Use Cases (Actualizado: 1/6/2026)

Implementar 2FA con Claude Code: TOTP, códigos de respaldo, recuperación y Step-Up Auth

Guía práctica para implementar 2FA/MFA en Next.js con Claude Code: TOTP, recuperación, límites, passkeys y auditoría.

Implementar 2FA con Claude Code: TOTP, códigos de respaldo, recuperación y Step-Up Auth

2FA no es solo una pantalla con un código QR. Una implementación seria de 2FA/MFA debe cubrir TOTP, códigos de respaldo, step-up auth para acciones sensibles, recuperación de cuenta, rate limits, dispositivos recordados, auditoría y una ruta clara hacia WebAuthn/passkeys.

Esta guía muestra cómo usar Claude Code para construir esa pieza en una aplicación Next.js App Router con TypeScript y Prisma. Para el inicio de sesión base revisa la guía JWT con Claude Code, para recuperación de contraseña la guía de password reset, para permisos la guía RBAC, y para controlar lo que puede tocar el agente la guía de seguridad de Claude Code.

Las referencias externas usadas son OWASP MFA Cheat Sheet, OWASP WSTG sobre pruebas MFA, MDN Web Authentication API y W3C WebAuthn Level 3. La idea central es que MFA no termina al activarse: también hay que revisar almacenamiento de OTP, intentos fallidos, recuperación y cambios de factor.

Define el límite de seguridad

TOTP significa contraseña de un solo uso basada en tiempo. El servidor y la app de autenticación comparten un secreto y generan un código corto, normalmente cada 30 segundos. MFA aumenta la confianza de una sesión, pero no sustituye la autorización. Invitar administradores, cambiar facturación, emitir API keys, cambiar contraseña o exportar datos personales debe exigir una verificación MFA reciente.

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

Entrega este límite a Claude Code antes de pedir código. No pidas “implementa 2FA” en un solo bloque. Divide el trabajo en schema, helpers criptográficos, setup API, enable API, challenge de login, recuperación, remembered device, audit logs y pruebas. Las claves reales, KMS, permisos de producción y reglas de soporte deben permanecer bajo control humano.

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.

En revisión busca problemas concretos: secreto TOTP en texto claro, códigos de respaldo en texto claro o en logs, códigos reutilizables, ausencia de rate limit para el código de 6 dígitos, tokens de remembered device guardados sin hash, recuperación controlada por una sola persona de soporte y falta de auditoría al activar, desactivar, fallar, recuperar o cambiar factores.

Modelo de datos

El secreto TOTP se cifra porque el servidor tendrá que leerlo. Los códigos de respaldo y tokens de dispositivo solo se comparan, así que se guardan como HMAC.

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

Helpers de cifrado, hash y rate limit

// 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;
}

El limiter en memoria sirve para desarrollo local. En producción, pide a Claude Code reemplazarlo por Redis o Upstash manteniendo la misma firma, para que las rutas y pruebas no cambien.

API de activación y códigos de respaldo

El setup crea el secreto y el QR, pero no activa 2FA. La activación ocurre cuando el usuario demuestra que puede generar un TOTP válido. Los backup codes se devuelven una sola vez.

// 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 });
}

Durante el login, acepta TOTP o un backup code sin usar. Si un backup code funciona, marca usedAt de inmediato. Para remembered device, genera un token aleatorio, guarda solo su HMAC y envía el token real en una cookie httpOnly, secure, sameSite y revocable.

UI mínima

// 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>
  );
}

Recuperación, SMS y passkeys

La recuperación suele ser el punto débil. Usa primero backup codes. Si el usuario perdió todos los factores, combina confirmación por correo, una sesión confiable existente si la hay, aprobación de owner en B2B y un periodo de espera antes de acciones críticas. Notifica al usuario y revoca sesiones antiguas.

SMS puede ser mejor que nada, pero sufre SIM swap, reciclaje de números y abuso de soporte del operador. No lo uses como factor principal para administradores, pagos o API keys.

WebAuthn/passkeys es la evolución natural porque usa credenciales de clave pública vinculadas al origen. Empieza con TOTP bien hecho y deja el modelo preparado para una tabla de credenciales WebAuthn.

// 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.");
  }
}

Casos reales y errores comunes

Caso 1: invitaciones de administrador. Exige mfaAt reciente antes de invitar owners o admins. Una cookie robada no debería bastar para tomar una organización.

Caso 2: cambios de facturación. Pide step-up auth al cambiar tarjeta, correo de factura, reembolsos o datos fiscales. Audita el evento, no el número de tarjeta ni códigos de respaldo.

Caso 3: creación de API keys. Exige MFA antes de crear claves o ampliar scopes, y combínalo con RBAC para permitirlo solo a usuarios con api_key:create.

Caso 4: recuperación por soporte. El agente debe crear una solicitud, no desactivar MFA directamente. Añade doble aprobación, notificación al usuario, espera y auditoría.

Los errores más frecuentes son secret TOTP en texto claro, backup codes reutilizables, intentos ilimitados de códigos de 6 dígitos, tokens de remembered device guardados sin hash, desactivar MFA sin step-up, enlaces de recuperación demasiado largos y logs con secretos.

Cierre

2FA robusto es un proyecto de confianza de sesión, recuperación y auditoría. Claude Code ayuda cuando cada tarea es pequeña y revisable; no debe decidir políticas de seguridad por ti. ClaudeCodeLab puede revisar tu flujo MFA actual, formar equipos en seguridad con Claude Code o implementar una hoja de ruta de TOTP a passkeys en productos Next.js.

Al probar esta guía, verifica claves distintas por entorno, backup codes mostrados una sola vez, rate limit en fallos TOTP, cookie de remembered device httpOnly y revocable, step-up para desactivar 2FA y audit logs sin secretos.

#Claude Code #2FA #MFA #TOTP #seguridad
Gratis

PDF gratis: cheatsheet de Claude Code

Introduce tu email y descarga una hoja con comandos, hábitos de revisión y flujos seguros.

Cuidamos tus datos y no enviamos spam.

Masa

Sobre el autor

Masa

Ingeniero enfocado en workflows prácticos con Claude Code.