Implémenter la 2FA avec Claude Code : TOTP, codes de secours, récupération et Step-Up Auth
Guide pratique pour ajouter 2FA/MFA dans Next.js avec Claude Code : TOTP, recovery, limites, passkeys et audit.
La 2FA ne se résume pas à afficher un QR code. Une vraie conception 2FA/MFA doit couvrir TOTP, codes de secours, step-up auth pour les actions sensibles, récupération de compte, rate limits, appareils mémorisés, journaux d’audit et évolution vers WebAuthn/passkeys.
Cet article explique comment confier ce travail à Claude Code dans une application Next.js App Router avec TypeScript et Prisma, sans lui demander un gros patch impossible à relire. Pour la base de connexion, lisez le guide JWT avec Claude Code. Pour la réinitialisation de mot de passe, utilisez le guide password reset. Pour les permissions, voyez le guide RBAC. Pour limiter les droits de l’agent, gardez sous la main les bonnes pratiques sécurité Claude Code.
Les références externes sont OWASP MFA Cheat Sheet, OWASP WSTG pour les tests MFA, MDN Web Authentication API et W3C WebAuthn Level 3. Elles insistent sur le stockage des OTP, les échecs, la récupération et le changement de facteur, pas seulement sur l’écran d’activation.
Fixer la frontière de sécurité
TOTP signifie time-based one-time password. Le serveur et l’application d’authentification partagent un secret et génèrent un code court, souvent toutes les 30 secondes. MFA augmente le niveau d’assurance de la session, mais ne remplace pas l’autorisation. Inviter un administrateur, modifier la facturation, créer une clé API, changer le mot de passe ou exporter des données personnelles doit exiger une MFA récente.
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
Donnez cette frontière à Claude Code avant le code. Divisez le travail en schéma de données, helpers crypto, setup API, enable API, challenge de connexion, récupération, remembered device, audit logs et tests. Les vrais secrets, KMS, permissions de production et règles de support doivent rester décidés par un humain.
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 revue, cherchez les défauts concrets : secret TOTP en clair, codes de secours en clair ou dans les logs, codes réutilisables, aucun rate limit sur les 6 chiffres, token remembered device stocké brut, récupération MFA possible par un seul agent support, absence d’audit pour activation, désactivation, échec, recovery ou changement de facteur.
Modèle de données
Le secret TOTP doit être chiffré, car le serveur doit le relire. Les codes de secours et tokens d’appareils mémorisés doivent être hachés, car une comparaison suffit.
// 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, hash et 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;
}
Le rate limit en mémoire est suffisant pour un exemple local. En production, remplacez-le par Redis ou Upstash. Demandez à Claude Code de conserver la même signature pour éviter de casser les routes.
API d’activation et codes de secours
Le setup crée le secret et le QR code, mais n’active pas la 2FA. L’activation ne se fait qu’après vérification d’un premier TOTP. Les codes de secours ne sont affichés qu’une fois.
// 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 });
}
Le challenge de login doit accepter TOTP ou un code de secours non utilisé. Après succès d’un code de secours, remplissez usedAt immédiatement. Pour un appareil mémorisé, stockez seulement le HMAC du token et envoyez le token réel dans un cookie httpOnly, secure, sameSite, court et révocable.
Interface
// 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 et passkeys
La récupération doit d’abord passer par les codes de secours. Si tout est perdu, combinez email, session déjà fiable, approbation d’un owner en B2B et délai avant actions critiques. Prévenez l’utilisateur et révoquez les anciennes sessions.
SMS peut être utile comme transition, mais reste exposé au SIM swap, au recyclage de numéro et à l’abus du support opérateur. Ne l’utilisez pas comme premier facteur pour admins, paiements ou API keys.
WebAuthn/passkeys est la direction long terme grâce aux identifiants à clé publique liés à l’origine. Commencez par un TOTP solide, puis ajoutez une table de credentials WebAuthn avec les mêmes règles d’audit, révocation et 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.");
}
}
Cas d’usage et pièges
Cas 1 : invitation d’administrateur. Exigez un mfaAt récent avant d’ajouter un owner ou admin. Un cookie volé ne doit pas suffire.
Cas 2 : facturation. Demandez une MFA fraîche pour carte, email de facture, remboursement et données fiscales, sans journaliser de secrets.
Cas 3 : API keys. Exigez MFA avant création ou extension de scope, et combinez avec RBAC pour limiter à api_key:create.
Cas 4 : récupération support. Le support crée une demande, puis double validation, notification, délai et audit. Il ne désactive pas directement MFA.
Les pièges typiques sont secret TOTP en clair, code de secours réutilisable, absence de rate limit, token remembered device brut en base, désactivation sans step-up, lien de recovery trop long, logs contenant des secrets.
Conclusion
Une 2FA solide est un projet de confiance de session, récupération, audit et opérations. Claude Code est efficace si chaque tâche est petite et relue. ClaudeCodeLab peut auditer votre flux MFA, former votre équipe aux workflows sécurisés avec Claude Code ou construire une trajectoire de TOTP vers passkeys dans Next.js.
Avant de tester en production, vérifiez les clés par environnement, l’affichage unique des backup codes, le rate limit TOTP, le cookie remembered device révocable, le step-up pour désactiver 2FA et l’absence de secrets dans les audit logs.
PDF gratuit: cheatsheet Claude Code
Saisissez votre email et téléchargez une page avec commandes, habitudes de review et workflow sûr.
Nous protégeons vos données et n'envoyons pas de spam.
À propos de l'auteur
Masa
Ingénieur spécialisé dans les workflows pratiques avec Claude Code.
Articles liés
Workflow Obsidian vers CLAUDE.md avec Claude Code
Transformer des notes Obsidian en notes CLAUDE.md concises pour reprendre les sessions sans réexpliquer.
Claude Code Revenue CTA Routing : relier articles, PDF, Gumroad et consultation
Un workflow Claude Code pour orienter les lecteurs vers PDF gratuit, Gumroad ou consultation selon l'intention.
Règles de handoff Claude Code en équipe: preuves, permissions, rollback et revenus
Un format concret pour transmettre un travail Claude Code avec preuves, permissions, rollback, PDF gratuit, Gumroad et consultation.