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.
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]
| Sujet | Choix | Pourquoi |
|---|---|---|
| Existence utilisateur | Même réponse pour email connu ou inconnu | Évite l’énumération |
| Token | crypto.randomBytes(32).toString("base64url") | Valeur difficile à deviner |
| Stockage | Hash SHA-256 uniquement | Une fuite DB ne donne pas de lien valide |
| Durée | 30 minutes | Fenêtre de risque courte |
| Usage | Une seule fois | Bloque les réutilisations depuis historique ou logs |
| rate limit | Par IP et email normalisé | Réduit spam et brute force |
| Ne jamais envoyer le mot de passe | L’email n’est pas un coffre-fort | |
| Page reset | noindex et Referrer-Policy: no-referrer | Évite indexation et fuite Referer |
| Après reset | Révoquer les sessions, pas d’auto-login | Nettoie les sessions compromises |
| MFA | Pas de reset MFA ici | MFA demande un processus séparé |
Ce qu’il faut demander à Claude Code
Découpez le travail pour rendre la revue possible.
- Ajouter
PasswordResetToken,SessionetAuditLogau schéma Prisma. - Créer le service Node crypto avec hash, expiration, usage unique et rate limit.
- Ajouter les routes request et confirm en Next.js App Router.
- Ajouter email, page reset,
Referrer-Policy,X-Robots-Taget headers cache. - 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.
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.