Use Cases (Atualizado: 01/06/2026)

Redefinição de senha segura com Claude Code

Implemente password reset seguro com Claude Code: token aleatório, hash no banco, rate limit, email, sessões, MFA e auditoria.

Redefinição de senha segura com Claude Code

Redefinição de senha não é apenas uma tela de conveniência. É um caminho de recuperação de conta. Se esse caminho for mais fraco que o login, o atacante vai usá-lo. Os erros mais comuns são revelar se o email existe, salvar token em texto puro, deixar links sem expiração, aceitar o mesmo token mais de uma vez, manter sessões antigas ativas ou tratar recuperação de MFA como se fosse a mesma coisa que redefinir senha.

Claude Code consegue implementar esse fluxo com qualidade quando a tarefa é específica. Em vez de pedir “crie uma página de esqueci minha senha”, peça um workflow revisável: resposta genérica, token aleatório, hash do token no banco, validade curta, uso único, rate limit, template de email, Referrer-Policy, noindex, invalidação de sessões, audit log e separação de MFA.

Este artigo segue as recomendações da OWASP em Forgot Password Cheat Sheet, Authentication Cheat Sheet e Testing for Weak Password Change or Reset Functionalities. Para a visão de segurança do projeto, leia também boas práticas de segurança com Claude Code e gestão de variáveis de ambiente.

Fluxo seguro

O token de reset é uma chave temporária. O usuário recebe essa chave por email, mas o banco deve guardar apenas o hash dela. Assim, um vazamento de backup ou banco não vira automaticamente acesso a links válidos.

flowchart TD
  A[Usuário informa email] --> B[API retorna sempre a mesma mensagem]
  B --> C{rate limit ok}
  C -- não --> Z[Não revelar se a conta existe]
  C -- sim --> D{Usuário existe}
  D -- não --> Z
  D -- sim --> E[Gerar token aleatório com Node crypto]
  E --> F[Salvar somente hash SHA-256 do token]
  F --> G[Enviar link HTTPS por email]
  G --> H[Página com noindex e Referrer-Policy]
  H --> I[Usuário envia nova senha]
  I --> J[Consumir token uma vez em transação]
  J --> K[Gerar hash da senha e revogar sessões]
  K --> L[Registrar auditoria e pedir novo login]
ÁreaDecisãoMotivo
Existência da contaMesma resposta para email existente e inexistenteEvita enumeração
Tokencrypto.randomBytes(32).toString("base64url")Valor difícil de adivinhar
ArmazenamentoApenas hash SHA-256Vazamento do banco não expõe link válido
Validade30 minutosLimita janela de risco
UsoUma única vezEvita reutilização por histórico, logs ou reenvio
rate limitPor IP e email normalizadoReduz spam e força bruta
EmailNunca enviar a senhaEmail não é cofre de segredo
Páginanoindex e Referrer-Policy: no-referrerEvita indexação e vazamento por Referer
Após resetRevogar sessões e não fazer auto-loginRemove sessões comprometidas
MFANão redefinir MFA neste fluxoRecuperação de MFA exige processo separado

Como orientar o Claude Code

Divida o trabalho para que cada parte possa ser revisada.

  1. Adicionar PasswordResetToken, Session e AuditLog ao schema Prisma.
  2. Criar serviço com Node crypto, hash, expiração, uso único e rate limit.
  3. Criar APIs de request e confirmação no Next.js App Router.
  4. Criar template de email, página de reset, Referrer-Policy, X-Robots-Tag e cache headers.
  5. Criar testes de não enumeração, token expirado, token reutilizado e revogação de sessão.

Prompt recomendado:

Implemente password reset em uma app Next.js App Router + TypeScript + Prisma.
Restrições:
- Emails existentes e inexistentes retornam o mesmo JSON e tempos parecidos
- Gerar token com Node crypto, no mínimo 32 bytes, e salvar só SHA-256 hash no banco
- Token expira em 30 minutos e só pode ser usado uma vez
- Aplicar rate limit por IP e email normalizado
- Página de reset com noindex e Referrer-Policy no-referrer
- Após sucesso, revogar todas as sessões e não fazer auto-login
- Não implementar MFA reset; recuperação de MFA fica em fluxo separado
- Não logar token bruto, reset URL nem password
Altere apenas arquivos necessários de auth, Prisma, email e testes.

Prisma schema

Este modelo é mínimo. Se a aplicação já tem User ou Session, incorpore os campos ao 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])
}

Serviço com crypto e email

O código abaixo cobre token aleatório, hash, rate limit simples, template de email, auditoria, troca de senha e revogação de sessões. Em produção, troque o Map por Redis ou serviço compartilhado.

// 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 routes e headers

A API de solicitação sempre retorna o mesmo corpo. A API de confirmação só informa que o link é inválido ou expirou.

// 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 práticos

Primeiro caso: usuário comum de SaaS esquece a senha. Expiração curta, uso único e revogação de sessões reduzem risco de celular antigo, navegador compartilhado ou sessão roubada.

Segundo caso: conta administradora. Depois do reset, a pessoa deve voltar ao login normal e passar pelo MFA. Perda de autenticador deve seguir um fluxo separado, como em autenticação de dois fatores.

Terceiro caso: suporte recebe “não chegou o email”. O audit log deve mostrar horário, IP, User-Agent e status de envio, mas nunca o token bruto.

Quarto caso: cliente B2B sofre flood de emails de reset. Limites por IP e email geram sinal antes que o suporte fique sobrecarregado.

Falhas comuns e revisão

A falha mais perigosa é responder “email não cadastrado”. Isso vira uma API de enumeração. Diferença de tempo de resposta causa o mesmo vazamento.

Outra falha grave é salvar token bruto em banco, logs, monitoramento ou ferramenta de suporte. Permissão de leitura vira permissão de assumir conta.

Também bloqueie tokens sem expiração, tokens reutilizáveis, URL criada a partir do header Host, auto-login após reset e qualquer mudança de MFA no mesmo diff.

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

Conclusão

Password reset é um pequeno sistema de autenticação. Ao usar Claude Code, transforme segurança em critérios claros: sem enumeração, token aleatório, hash no banco, validade curta, uso único, rate limit, email seguro, noindex, Referrer-Policy, revogação de sessões, auditoria e MFA separado.

ClaudeCodeLab pode ajudar com revisão, treinamento ou implementação de fluxos de autenticação com Claude Code. Com login atual, modelo de sessão, provedor de email e política de MFA, já dá para separar o que pode ser gerado e o que precisa de revisão humana.

Ao testar este artigo, confira quatro pontos: email real e email inexistente retornam a mesma resposta, o banco não contém token bruto, tokens expirados ou usados falham, e sessões antigas não acessam APIs protegidas. Masa começa por essas verificações antes de transformá-las em testes automatizados.

#Claude Code #password reset #authentication #security #email
Grátis

PDF grátis: cheatsheet do Claude Code

Informe seu e-mail e baixe uma página com comandos, hábitos de revisão e workflows seguros.

Cuidamos dos seus dados e não enviamos spam.

Masa

Sobre o autor

Masa

Engenheiro focado em workflows práticos com Claude Code.