Use Cases (Mis à jour: 01/06/2026)

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.

Implémenter la 2FA avec Claude Code : TOTP, codes de secours, récupération et Step-Up Auth

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.

#Claude Code #2FA #MFA #TOTP #sécurité
Gratuit

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.

Masa

À propos de l'auteur

Masa

Ingénieur spécialisé dans les workflows pratiques avec Claude Code.