Restablecimiento de contraseña seguro con Claude Code
Implementa password reset seguro con Claude Code: tokens aleatorios, hash en BD, límites, email, sesiones, MFA y auditoría.
El restablecimiento de contraseña no es solo una pantalla de ayuda. Es una ruta alternativa para recuperar una cuenta. Si esa ruta es más débil que el login, un atacante no intentará romper el login: usará el flujo de reset. Los fallos típicos son responder “ese email no existe”, guardar tokens en texto claro, permitir enlaces sin caducidad, aceptar el mismo token varias veces, dejar sesiones antiguas activas o mezclar el reset de MFA con el reset de contraseña.
Claude Code puede implementar este flujo con buena calidad si le das límites claros. La petición no debe ser “haz una página de olvidé mi contraseña”, sino “implementa un flujo revisable con respuesta genérica, token aleatorio, hash del token, caducidad corta, un solo uso, rate limit, plantilla de correo, Referrer-Policy, noindex, revocación de sesiones, audit log y separación de MFA”.
La base de este artículo son las guías de OWASP: Forgot Password Cheat Sheet, Authentication Cheat Sheet y Testing for Weak Password Change or Reset Functionalities. Para el contexto de seguridad de Claude Code, complementa con buenas prácticas de seguridad y gestión de variables de entorno.
Flujo recomendado
Piensa en el token como una llave temporal. El usuario la recibe por email, pero la base de datos no debe guardar la llave original. Guarda solo un hash para que una fuga de la base de datos no se convierta automáticamente en enlaces de recuperación válidos.
flowchart TD
A[El usuario introduce su email] --> B[La API devuelve siempre el mismo mensaje]
B --> C{rate limit correcto}
C -- no --> Z[No revelar si la cuenta existe]
C -- si --> D{Existe el usuario}
D -- no --> Z
D -- si --> E[Generar token aleatorio con Node crypto]
E --> F[Guardar solo hash SHA-256 del token]
F --> G[Enviar enlace HTTPS por email]
G --> H[Página con noindex y Referrer-Policy]
H --> I[El usuario envía nueva contraseña]
I --> J[Consumir token una vez en transacción]
J --> K[Hashear contraseña y revocar sesiones]
K --> L[Registrar auditoría y pedir nuevo login]
| Área | Decisión | Motivo |
|---|---|---|
| Existencia de usuario | Misma respuesta para emails existentes y no existentes | Evita enumeración de cuentas |
| Token | crypto.randomBytes(32).toString("base64url") | Valor difícil de adivinar |
| Almacenamiento | Solo hash SHA-256 del token | Una fuga de BD no revela enlaces útiles |
| Caducidad | 30 minutos | Reduce la ventana de abuso |
| Uso | Una sola vez | Evita reutilización desde historial, logs o reenvíos |
| rate limit | Por IP y email normalizado | Reduce spam de emails y fuerza bruta |
| Nunca enviar la contraseña | El correo no es un almacén secreto | |
| Página de reset | noindex y Referrer-Policy: no-referrer | Evita indexación y fuga por Referer |
| Después del reset | Revocar sesiones y no auto-login | Limpia sesiones robadas |
| MFA | No restablecer MFA aquí | MFA requiere un flujo de recuperación separado |
Qué pedirle a Claude Code
Divide el trabajo en unidades pequeñas. Así puedes revisar el diff sin perderte.
- Añadir
PasswordResetToken,SessionyAuditLogal schema de Prisma. - Crear el servicio con Node crypto, hash de token, expiración, uso único y rate limit.
- Crear las rutas API de request y confirmación en Next.js App Router.
- Añadir plantilla de email, página de reset,
Referrer-Policy,X-Robots-Tagy cabeceras de caché. - Añadir pruebas de no enumeración, token caducado, token reutilizado y revocación de sesiones.
Un prompt útil para Claude Code sería:
Implementa password reset en una app Next.js App Router + TypeScript + Prisma.
Restricciones:
- Emails existentes y no existentes deben devolver el mismo JSON y tiempos parecidos
- Genera tokens con Node crypto, mínimo 32 bytes, y guarda solo SHA-256 hash
- El token caduca en 30 minutos y solo se usa una vez
- Aplica rate limit por IP y email normalizado
- La página de reset debe tener noindex y Referrer-Policy no-referrer
- Tras el reset revoca todas las sesiones y no hagas auto-login
- No implementes reset de MFA; MFA recovery es otro flujo
- No registres token original, reset URL ni password en logs
Modifica solo archivos de auth, Prisma, email y tests necesarios.
Prisma schema
Este modelo es un punto de partida. Si tu aplicación ya tiene User o Session, integra los campos en el modelo existente.
// 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])
}
Servicio con crypto, límites y email
El siguiente código es funcional para una app pequeña. Instala @prisma/client, bcryptjs y zod. En producción, sustituye el Map de rate limit por Redis o un almacén compartido.
// 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." };
});
}
Rutas API y cabeceras
La ruta de solicitud siempre devuelve el mismo cuerpo. La ruta de confirmación no revela nada del usuario; solo informa si el enlace no sirve.
// 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;
Casos de uso reales
El primer caso es un usuario normal de SaaS. Restablece su contraseña desde el email. La caducidad corta, el uso único y la revocación de sesiones reducen el riesgo de móviles antiguos, navegadores compartidos o sesiones robadas.
El segundo caso es una cuenta administradora. Después del reset debe volver al login normal y pasar MFA. Si perdió el autenticador, usa un flujo separado como el de autenticación de dos factores, con verificación más fuerte.
El tercer caso es soporte. Cuando alguien dice “no llegó el email”, el equipo debe ver la hora de solicitud, IP, User-Agent y estado de entrega, pero nunca el token original.
El cuarto caso es un cliente B2B con abuso de emails de reset. Los límites por IP y por email dan una señal temprana antes de que la cola de soporte se llene.
Errores frecuentes y revisión
El error más grave es responder “email no registrado”. Convierte el endpoint en una herramienta de enumeración. La diferencia de tiempos también filtra información si los emails inexistentes salen por una ruta rápida.
Guardar tokens sin hash es otro problema serio. Si un token aparece en base de datos, logs, soporte o monitorización, cualquier lector de esos sistemas puede restablecer cuentas.
También revisa tokens sin caducidad, tokens reutilizables, URLs construidas desde el header Host, auto-login tras el reset y cambios de MFA dentro del mismo diff. Todo eso debe bloquearse en review.
npm install @prisma/client bcryptjs zod
npx prisma migrate dev --name add_password_reset
npm run lint
npm test
Cierre
Un password reset es un sistema de autenticación pequeño. Si lo implementas con Claude Code, convierte los requisitos de seguridad en criterios de aceptación: no enumeración, tokens aleatorios, hash en BD, caducidad corta, un solo uso, rate limit, correo seguro, noindex, Referrer-Policy, revocación de sesiones, auditoría y MFA separado.
ClaudeCodeLab puede ayudar con revisión, formación o implementación de flujos de autenticación usando Claude Code. Con el login actual, el modelo de sesiones, el proveedor de email y la política MFA ya se puede decidir qué generar y qué revisar manualmente.
Al probar este artículo, verifica cuatro puntos: email real y falso devuelven lo mismo, la BD no contiene tokens originales, tokens caducados o usados fallan, y las sesiones antiguas dejan de acceder a APIs protegidas. Masa empieza por esas comprobaciones antes de convertirlas en tests automatizados.
PDF gratis: cheatsheet de Claude Code
Introduce tu email y descarga una hoja con comandos, hábitos de revisión y flujos seguros.
Cuidamos tus datos y no enviamos spam.
Sobre el autor
Masa
Ingeniero enfocado en workflows prácticos con Claude Code.
Artículos relacionados
Workflow de Obsidian a CLAUDE.md con Claude Code
Convierte notas de trabajo de Obsidian en notas operativas de CLAUDE.md para no repetir contexto.
Claude Code Revenue CTA Routing: de artículos a PDF, Gumroad y consulta
Un flujo con Claude Code para dirigir lectores a PDF gratis, Gumroad o consulta según intención.
Reglas de handoff para equipos con Claude Code: evidencia, permisos, rollback e ingresos
Formato práctico para entregar trabajo de Claude Code con pruebas, permisos, rollback, PDF gratis, Gumroad y consulta.