Implementar 2FA com Claude Code: TOTP, códigos de backup, recuperação e Step-Up Auth
Guia prático para criar 2FA/MFA em Next.js com Claude Code: TOTP, backup, recuperação, limites, passkeys e auditoria.
2FA não é apenas uma tela com QR code. Uma implementação séria de 2FA/MFA precisa tratar TOTP, códigos de backup, step-up auth para ações sensíveis, recuperação de conta, rate limits, dispositivos lembrados, logs de auditoria e uma evolução natural para WebAuthn/passkeys.
Este guia mostra como usar Claude Code em uma aplicação Next.js App Router com TypeScript e Prisma. Para a base de login, veja o guia de autenticação JWT com Claude Code. Para recuperação de senha, consulte o guia de password reset. Para permissões, leia o guia de RBAC. Para limitar o que o agente pode alterar, use as boas práticas de segurança do Claude Code.
As referências externas são OWASP MFA Cheat Sheet, OWASP WSTG sobre testes de MFA, MDN Web Authentication API e W3C WebAuthn Level 3. O ponto importante é que MFA inclui armazenamento, falhas, recuperação e troca de fatores.
Defina a fronteira de segurança
TOTP significa time-based one-time password. O servidor e o app autenticador compartilham um segredo e geram um código curto, normalmente a cada 30 segundos. MFA aumenta a confiança da sessão, mas não substitui autorização. Convite de administradores, mudanças de cobrança, criação de API key, troca de senha e exportação de dados devem exigir MFA recente.
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
Passe essa fronteira para o Claude Code antes de pedir implementação. Não peça “faça 2FA” de uma vez. Divida em schema, helpers criptográficos, setup API, enable API, desafio de login, recuperação, remembered device, audit logs e testes. Chaves reais, KMS, permissões de produção e regras de suporte ficam sob decisão humana.
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.
Na revisão, procure falhas concretas: segredo TOTP em texto claro, códigos de backup em texto claro ou nos logs, códigos reutilizáveis, ausência de rate limit no código de 6 dígitos, token de remembered device salvo bruto, recuperação feita por uma única pessoa do suporte e falta de auditoria para ativar, desativar, falhar, recuperar ou trocar fatores.
Modelo de dados
O segredo TOTP é criptografado porque o servidor precisa lê-lo depois. Códigos de backup e tokens de remembered device são comparados, então basta guardar 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"
Criptografia, hash e 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;
}
Esse limitador em memória serve para desenvolvimento. Em produção, peça para Claude Code trocar por Redis ou Upstash mantendo a assinatura da função.
API de ativação e códigos de backup
O setup cria secret e QR code, mas não ativa 2FA. A ativação acontece quando o usuário prova que consegue gerar um TOTP válido. Os códigos de backup são retornados uma única 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 });
}
No login, aceite TOTP ou um backup code ainda não usado. Se o backup code funcionar, grave usedAt imediatamente. Para remembered device, gere token aleatório, salve apenas o HMAC e envie o token real em cookie httpOnly, secure, sameSite, com expiração curta e opção de revogação.
Componente de UI
// 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>
);
}
Recuperação, SMS e passkeys
Recuperação costuma ser o ponto fraco. Use primeiro códigos de backup. Se todos os fatores foram perdidos, combine confirmação por email, sessão confiável existente, aprovação do owner em B2B e período de espera antes de ações críticas. Notifique o usuário e revogue sessões antigas.
SMS pode ser melhor que nada em alguns fluxos, mas sofre com SIM swap, reciclagem de número e abuso de suporte da operadora. Não deve ser o fator principal para administradores, pagamentos ou API keys.
WebAuthn/passkeys é o caminho mais resistente a phishing, pois usa credenciais de chave pública presas à origem. Comece com TOTP sólido e deixe espaço para uma tabela de credenciais WebAuthn com as mesmas regras de auditoria, revogação e step-up.
// 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 de uso e armadilhas
Caso 1: convite de administrador. Exija mfaAt recente antes de convidar owner ou admin. Cookie roubado não pode bastar para tomar a organização.
Caso 2: cobrança. Mudança de cartão, email de fatura, reembolso e dados fiscais exigem step-up auth. Audite o evento, não os dados sensíveis.
Caso 3: API keys. Criar chave ou ampliar scope exige MFA e RBAC, por exemplo api_key:create.
Caso 4: suporte. O atendente cria uma solicitação de recuperação, não desativa MFA diretamente. Use dupla aprovação, notificação, espera e auditoria.
Armadilhas comuns: segredo TOTP em texto claro, backup code reutilizável, código de 6 dígitos sem limite de tentativas, token de remembered device bruto no banco, desativar MFA sem step-up, link de recuperação longo demais e logs com segredos.
Fechamento
2FA robusto é um projeto de confiança de sessão, recuperação, auditoria e operação. Claude Code funciona bem quando cada tarefa é pequena e revisável. ClaudeCodeLab pode auditar seu fluxo MFA, treinar equipes em segurança com Claude Code ou implementar uma rota de TOTP para passkeys em produtos Next.js.
Ao testar, confirme chaves separadas por ambiente, backup codes exibidos uma vez, rate limit em falhas TOTP, cookie de remembered device httpOnly e revogável, step-up para desativar 2FA e audit logs sem valores secretos.
PDF grátis: cheatsheet do Claude Code
Informe seu e-mail e baixe uma página com comandos, hábitos de revisão e workflows seguros.
Cuidamos dos seus dados e não enviamos spam.
Sobre o autor
Masa
Engenheiro focado em workflows práticos com Claude Code.
Artigos relacionados
Workflow Obsidian para CLAUDE.md com Claude Code
Transforme notas de trabalho do Obsidian em notas operacionais CLAUDE.md para preservar contexto.
Claude Code Revenue CTA Routing: artigos para PDF, Gumroad e consultoria
Um fluxo com Claude Code para levar leitores ao PDF grátis, Gumroad ou consultoria conforme intenção.
Regras de handoff para equipes com Claude Code: evidências, permissões, rollback e receita
Formato prático para entregar trabalho do Claude Code com prova, permissões, rollback, PDF grátis, Gumroad e consultoria.