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

Réinitialisation de mot de passe sécurisée avec Claude Code

Implémentez un password reset sûr avec Claude Code : tokens aléatoires, hash en base, limites, email, sessions, MFA et audit.

Réinitialisation de mot de passe sécurisée avec Claude Code

La réinitialisation de mot de passe n’est pas une simple page de confort. C’est un chemin de récupération de compte. Si ce chemin est plus faible que le login, l’attaquant l’utilisera. Les erreurs fréquentes sont connues : révéler qu’une adresse email existe, stocker le token en clair, laisser un lien valable trop longtemps, accepter plusieurs usages du même token, garder les anciennes sessions ouvertes ou mélanger récupération MFA et reset de mot de passe.

Claude Code peut produire une bonne implémentation si la tâche est cadrée. Il ne faut pas demander seulement “crée une page mot de passe oublié”. Il faut demander un workflow complet et vérifiable : réponse identique, token aléatoire, stockage du hash, expiration courte, usage unique, rate limit, modèle d’email, Referrer-Policy, noindex, invalidation des sessions, logs d’audit et séparation stricte de MFA.

Ce guide s’appuie sur les références OWASP Forgot Password Cheat Sheet, Authentication Cheat Sheet et Testing for Weak Password Change or Reset Functionalities. Pour la posture générale, lisez aussi les bonnes pratiques de sécurité Claude Code et la gestion des variables d’environnement.

Workflow sécurisé

Le token de reset est une clé temporaire. L’utilisateur reçoit cette clé par email, mais la base de données ne doit stocker que son hash. Ainsi, une fuite de base ou de sauvegarde ne suffit pas à reconstruire un lien utilisable.

flowchart TD
  A[L'utilisateur saisit son email] --> B[Réponse identique dans tous les cas]
  B --> C{rate limit accepté}
  C -- non --> Z[Ne pas révéler l'existence du compte]
  C -- oui --> D{Utilisateur existant}
  D -- non --> Z
  D -- oui --> E[Générer un token aléatoire avec Node crypto]
  E --> F[Stocker uniquement le hash SHA-256]
  F --> G[Envoyer un lien HTTPS par email]
  G --> H[Page noindex avec Referrer-Policy]
  H --> I[Nouveau mot de passe soumis]
  I --> J[Consommer le token une seule fois en transaction]
  J --> K[Hasher le mot de passe et révoquer les sessions]
  K --> L[Écrire un audit log et demander une nouvelle connexion]
SujetChoixPourquoi
Existence utilisateurMême réponse pour email connu ou inconnuÉvite l’énumération
Tokencrypto.randomBytes(32).toString("base64url")Valeur difficile à deviner
StockageHash SHA-256 uniquementUne fuite DB ne donne pas de lien valide
Durée30 minutesFenêtre de risque courte
UsageUne seule foisBloque les réutilisations depuis historique ou logs
rate limitPar IP et email normaliséRéduit spam et brute force
EmailNe jamais envoyer le mot de passeL’email n’est pas un coffre-fort
Page resetnoindex et Referrer-Policy: no-referrerÉvite indexation et fuite Referer
Après resetRévoquer les sessions, pas d’auto-loginNettoie les sessions compromises
MFAPas de reset MFA iciMFA demande un processus séparé

Ce qu’il faut demander à Claude Code

Découpez le travail pour rendre la revue possible.

  1. Ajouter PasswordResetToken, Session et AuditLog au schéma Prisma.
  2. Créer le service Node crypto avec hash, expiration, usage unique et rate limit.
  3. Ajouter les routes request et confirm en Next.js App Router.
  4. Ajouter email, page reset, Referrer-Policy, X-Robots-Tag et headers cache.
  5. Ajouter des tests : non-énumération, token expiré, token réutilisé, sessions révoquées.

Prompt recommandé :

Implémente password reset dans une app Next.js App Router + TypeScript + Prisma.
Contraintes:
- Emails existants et inexistants retournent le même JSON et des temps proches
- Token généré avec Node crypto, minimum 32 bytes, stockage DB du SHA-256 hash seulement
- Token expiré après 30 minutes et utilisable une seule fois
- rate limit par IP et email normalisé
- Page reset avec noindex et Referrer-Policy no-referrer
- Après succès, révoquer toutes les sessions et ne pas connecter automatiquement
- Ne pas implémenter MFA reset; MFA recovery est un autre workflow
- Ne jamais logger token brut, reset URL ou password
Ne modifie que les fichiers auth, Prisma, email et tests nécessaires.

Schéma Prisma

Ce schéma est volontairement minimal. Si votre app possède déjà User ou Session, fusionnez les champs.

// prisma/schema.prisma
model User {
  id                String               @id @default(cuid())
  email             String               @unique
  passwordHash      String
  mfaEnabled        Boolean              @default(false)
  passwordChangedAt DateTime?
  createdAt         DateTime             @default(now())
  resetTokens       PasswordResetToken[]
  sessions          Session[]
  auditLogs         AuditLog[]
}

model PasswordResetToken {
  id                 String    @id @default(cuid())
  userId             String
  tokenHash          String    @unique
  expiresAt          DateTime
  usedAt             DateTime?
  createdAt          DateTime  @default(now())
  requestedIp        String?
  requestedUserAgent String?
  user               User      @relation(fields: [userId], references: [id], onDelete: Cascade)

  @@index([userId, expiresAt])
  @@index([createdAt])
}

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

  @@index([userId, revokedAt])
}

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

  @@index([userId, createdAt])
  @@index([event, createdAt])
}

Service TypeScript complet

Ce code couvre token aléatoire, hash, rate limit simple, email, audit log, changement de mot de passe et révocation de sessions. En production, remplacez le Map par Redis ou un service partagé.

// src/lib/password-reset.ts
import crypto from "node:crypto";
import bcrypt from "bcryptjs";
import { PrismaClient } from "@prisma/client";

const prisma = new PrismaClient();
const RESET_TTL_MINUTES = 30;
const TOKEN_BYTES = 32;
const MIN_RESPONSE_MS = 450;
export const REQUEST_RESPONSE = {
  message: "If an account exists for that email, a password reset link has been sent.",
};

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

function normalizeEmail(email: string) {
  return email.trim().toLowerCase();
}

function hashToken(token: string) {
  return crypto.createHash("sha256").update(token, "utf8").digest("hex");
}

function appOrigin() {
  const origin = process.env.APP_ORIGIN;
  if (!origin || !origin.startsWith("https://")) throw new Error("APP_ORIGIN must be HTTPS");
  return origin.replace(/\/$/, "");
}

function isLimited(key: string, max: number, windowMs: number) {
  const now = Date.now();
  const current = memoryLimits.get(key);
  if (!current || current.resetAt <= now) {
    memoryLimits.set(key, { count: 1, resetAt: now + windowMs });
    return false;
  }
  current.count += 1;
  return current.count > max;
}

async function padResponse(startedAt: number) {
  const elapsed = Date.now() - startedAt;
  if (elapsed < MIN_RESPONSE_MS) {
    await new Promise((resolve) => setTimeout(resolve, MIN_RESPONSE_MS - elapsed));
  }
}

async function sendPasswordResetEmail(input: { to: string; resetUrl: string }) {
  const html = `
    <div style="font-family:Arial,sans-serif;line-height:1.6;color:#111827">
      <h1 style="font-size:20px">Reset your password</h1>
      <p>This link expires in 30 minutes. If you did not request it, ignore this email.</p>
      <p><a href="${input.resetUrl}" style="display:inline-block;background:#2563eb;color:#fff;padding:12px 18px;border-radius:6px;text-decoration:none">Reset password</a></p>
    </div>
  `;

  if (!process.env.RESEND_API_KEY) {
    console.info("Password reset email skipped locally", { to: input.to, resetUrl: input.resetUrl });
    return;
  }

  const response = await fetch("https://api.resend.com/emails", {
    method: "POST",
    headers: {
      Authorization: `Bearer ${process.env.RESEND_API_KEY}`,
      "Content-Type": "application/json",
    },
    body: JSON.stringify({
      from: process.env.MAIL_FROM ?? "support@example.com",
      to: input.to,
      subject: "Reset your password",
      html,
    }),
  });

  if (!response.ok) throw new Error(`Failed to send reset email: ${response.status}`);
}

export async function requestPasswordReset(input: { email: string; ip?: string; userAgent?: string }) {
  const startedAt = Date.now();
  const email = normalizeEmail(input.email);
  const ip = input.ip ?? "unknown";
  const limited =
    isLimited(`password-reset:ip:${ip}`, 20, 60 * 60 * 1000) ||
    isLimited(`password-reset:email:${email}`, 5, 15 * 60 * 1000);

  if (limited) {
    await padResponse(startedAt);
    return REQUEST_RESPONSE;
  }

  const user = await prisma.user.findUnique({ where: { email }, select: { id: true, email: true } });

  if (user) {
    const token = crypto.randomBytes(TOKEN_BYTES).toString("base64url");
    const expiresAt = new Date(Date.now() + RESET_TTL_MINUTES * 60 * 1000);

    await prisma.passwordResetToken.create({
      data: {
        userId: user.id,
        tokenHash: hashToken(token),
        expiresAt,
        requestedIp: ip,
        requestedUserAgent: input.userAgent?.slice(0, 300),
      },
    });

    await prisma.auditLog.create({
      data: {
        userId: user.id,
        event: "password_reset_requested",
        ip,
        userAgent: input.userAgent?.slice(0, 300),
        metadata: { expiresAt: expiresAt.toISOString() },
      },
    });

    await sendPasswordResetEmail({
      to: user.email,
      resetUrl: `${appOrigin()}/reset-password?token=${encodeURIComponent(token)}`,
    });
  }

  await padResponse(startedAt);
  return REQUEST_RESPONSE;
}

export async function resetPassword(input: { token: string; newPassword: string; ip?: string; userAgent?: string }) {
  if (input.newPassword.length < 12 || input.newPassword.length > 128) {
    throw new Error("invalid_password");
  }

  const tokenHash = hashToken(input.token);
  const now = new Date();
  const passwordHash = await bcrypt.hash(input.newPassword, 12);

  return prisma.$transaction(async (tx) => {
    const resetToken = await tx.passwordResetToken.findFirst({
      where: { tokenHash, expiresAt: { gt: now }, usedAt: null },
      select: { id: true, userId: true },
    });
    if (!resetToken) throw new Error("invalid_or_expired_token");

    const claimed = await tx.passwordResetToken.updateMany({
      where: { id: resetToken.id, usedAt: null },
      data: { usedAt: now },
    });
    if (claimed.count !== 1) throw new Error("invalid_or_expired_token");

    await tx.user.update({
      where: { id: resetToken.userId },
      data: { passwordHash, passwordChangedAt: now },
    });
    await tx.session.updateMany({
      where: { userId: resetToken.userId, revokedAt: null },
      data: { revokedAt: now },
    });
    await tx.auditLog.create({
      data: {
        userId: resetToken.userId,
        event: "password_reset_completed",
        ip: input.ip,
        userAgent: input.userAgent?.slice(0, 300),
        metadata: { sessionsRevoked: true },
      },
    });

    return { message: "Password updated. Please sign in again." };
  });
}

API et headers

La route request ne doit pas devenir un outil d’énumération. La route confirm ne doit révéler que l’invalidité du lien.

// src/app/api/auth/password-reset/request/route.ts
import { NextResponse } from "next/server";
import { z } from "zod";
import { REQUEST_RESPONSE, requestPasswordReset } from "@/lib/password-reset";

const bodySchema = z.object({ email: z.string().email().max(320) });

function clientIp(request: Request) {
  return request.headers.get("x-forwarded-for")?.split(",")[0]?.trim() ?? "unknown";
}

export async function POST(request: Request) {
  const parsed = bodySchema.safeParse(await request.json().catch(() => null));
  if (!parsed.success) return NextResponse.json(REQUEST_RESPONSE, { status: 202 });

  await requestPasswordReset({
    email: parsed.data.email,
    ip: clientIp(request),
    userAgent: request.headers.get("user-agent") ?? undefined,
  });

  return NextResponse.json(REQUEST_RESPONSE, { status: 202 });
}
// src/app/api/auth/password-reset/confirm/route.ts
import { NextResponse } from "next/server";
import { z } from "zod";
import { resetPassword } from "@/lib/password-reset";

const bodySchema = z
  .object({
    token: z.string().min(32).max(256),
    password: z.string().min(12).max(128),
    confirmPassword: z.string().min(12).max(128),
  })
  .refine((value) => value.password === value.confirmPassword);

export async function POST(request: Request) {
  const parsed = bodySchema.safeParse(await request.json().catch(() => null));
  if (!parsed.success) {
    return NextResponse.json({ message: "The reset link is invalid or the password policy was not met." }, { status: 400 });
  }

  try {
    const result = await resetPassword({
      token: parsed.data.token,
      newPassword: parsed.data.password,
      userAgent: request.headers.get("user-agent") ?? undefined,
    });
    return NextResponse.json(result, { status: 200 });
  } catch {
    return NextResponse.json({ message: "The reset link is invalid or expired." }, { status: 400 });
  }
}
// next.config.mjs
const nextConfig = {
  async headers() {
    return [
      {
        source: "/reset-password",
        headers: [
          { key: "Referrer-Policy", value: "no-referrer" },
          { key: "X-Robots-Tag", value: "noindex, nofollow" },
          { key: "Cache-Control", value: "no-store" },
        ],
      },
    ];
  },
};

export default nextConfig;

Cas réels

Premier cas : un utilisateur SaaS oublie son mot de passe. L’expiration courte, l’usage unique et la révocation des sessions évitent qu’un ancien téléphone ou un navigateur partagé reste connecté.

Deuxième cas : un compte administrateur. Après reset, il doit repasser par le login normal et MFA. Si l’appareil MFA est perdu, utilisez un workflow séparé comme dans le guide authentification à deux facteurs.

Troisième cas : support client. Pour “je n’ai pas reçu l’email”, le support doit voir l’heure, l’IP, le User-Agent et l’état d’envoi, mais jamais le token brut.

Quatrième cas : un tenant B2B reçoit une vague de demandes de reset. Le rate limit par IP et email donne un signal avant que le support soit saturé.

Échecs à bloquer en revue

Le pire échec est “email inconnu”. Cela crée une API d’énumération. Une différence de temps peut produire la même fuite.

Le deuxième est le token en clair dans la base, les logs ou les outils support. Une permission de lecture devient alors une permission de prise de compte.

Bloquez aussi les tokens sans expiration, les tokens réutilisables, les URLs construites avec le header Host, l’auto-login après reset, et toute modification MFA dans le même diff.

npm install @prisma/client bcryptjs zod
npx prisma migrate dev --name add_password_reset
npm run lint
npm test

Conclusion

Un reset de mot de passe est un petit système d’authentification. Avec Claude Code, transformez les exigences en critères d’acceptation : pas d’énumération, token aléatoire, hash en base, expiration courte, usage unique, rate limit, email sûr, noindex, Referrer-Policy, révocation de sessions, audit log et MFA séparé.

ClaudeCodeLab peut accompagner une revue, une formation ou une implémentation avec Claude Code. Avec votre login actuel, votre modèle de session, votre fournisseur email et votre politique MFA, on peut décider rapidement ce que Claude Code peut générer et ce qui doit rester sous revue humaine.

Pour tester cet article en pratique, vérifiez d’abord quatre points : email réel et faux email donnent la même réponse, la base ne contient aucun token brut, les tokens expirés ou utilisés échouent, et les anciennes sessions ne peuvent plus appeler les API protégées. Masa commence par ces contrôles avant de les transformer en tests automatisés.

#Claude Code #password reset #authentication #security #email
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.