Use Cases (Actualizado: 1/6/2026)

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.

Restablecimiento de contraseña seguro con Claude Code

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]
ÁreaDecisiónMotivo
Existencia de usuarioMisma respuesta para emails existentes y no existentesEvita enumeración de cuentas
Tokencrypto.randomBytes(32).toString("base64url")Valor difícil de adivinar
AlmacenamientoSolo hash SHA-256 del tokenUna fuga de BD no revela enlaces útiles
Caducidad30 minutosReduce la ventana de abuso
UsoUna sola vezEvita reutilización desde historial, logs o reenvíos
rate limitPor IP y email normalizadoReduce spam de emails y fuerza bruta
EmailNunca enviar la contraseñaEl correo no es un almacén secreto
Página de resetnoindex y Referrer-Policy: no-referrerEvita indexación y fuga por Referer
Después del resetRevocar sesiones y no auto-loginLimpia sesiones robadas
MFANo 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.

  1. Añadir PasswordResetToken, Session y AuditLog al schema de Prisma.
  2. Crear el servicio con Node crypto, hash de token, expiración, uso único y rate limit.
  3. Crear las rutas API de request y confirmación en Next.js App Router.
  4. Añadir plantilla de email, página de reset, Referrer-Policy, X-Robots-Tag y cabeceras de caché.
  5. 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.

#Claude Code #password reset #authentication #security #email
Gratis

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.

Masa

Sobre el autor

Masa

Ingeniero enfocado en workflows prácticos con Claude Code.