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 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]
| Área | Decisão | Motivo |
|---|---|---|
| Existência da conta | Mesma resposta para email existente e inexistente | Evita enumeração |
| Token | crypto.randomBytes(32).toString("base64url") | Valor difícil de adivinhar |
| Armazenamento | Apenas hash SHA-256 | Vazamento do banco não expõe link válido |
| Validade | 30 minutos | Limita janela de risco |
| Uso | Uma única vez | Evita reutilização por histórico, logs ou reenvio |
| rate limit | Por IP e email normalizado | Reduz spam e força bruta |
| Nunca enviar a senha | Email não é cofre de segredo | |
| Página | noindex e Referrer-Policy: no-referrer | Evita indexação e vazamento por Referer |
| Após reset | Revogar sessões e não fazer auto-login | Remove sessões comprometidas |
| MFA | Não redefinir MFA neste fluxo | Recuperação de MFA exige processo separado |
Como orientar o Claude Code
Divida o trabalho para que cada parte possa ser revisada.
- Adicionar
PasswordResetToken,SessioneAuditLogao schema Prisma. - Criar serviço com Node crypto, hash, expiração, uso único e rate limit.
- Criar APIs de request e confirmação no Next.js App Router.
- Criar template de email, página de reset,
Referrer-Policy,X-Robots-Tage cache headers. - 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 ?? "[email protected]",
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.
Artigos relacionados
Autenticação segura com Claude Code: sessões Next.js, JWT e OAuth
Implemente autenticação segura com Claude Code: sessões Next.js, JWT, OAuth, CSRF, RBAC, auditoria e testes.
Como implementar perfis de usuário seguros com Claude Code
Crie perfis com banco de dados, formulário, validação, autorização, avatar e logs de auditoria.
OAuth com Claude Code: PKCE, state, nonce e armazenamento seguro de tokens
Guia prático de OAuth 2.1/2.0 Authorization Code + PKCE com Claude Code, cobrindo state, nonce e tokens.
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.
Sobre o autor
Masa
Engenheiro focado em workflows práticos com Claude Code.