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 ?? "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.
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.
Artigos relacionados
Workflow Obsidian para CLAUDE.md com Claude Code
Transforme notas de trabalho do Obsidian em notas operacionais CLAUDE.md para preservar contexto.
Claude Code Revenue CTA Routing: artigos para PDF, Gumroad e consultoria
Um fluxo com Claude Code para levar leitores ao PDF grátis, Gumroad ou consultoria conforme intenção.
Regras de handoff para equipes com Claude Code: evidências, permissões, rollback e receita
Formato prático para entregar trabalho do Claude Code com prova, permissões, rollback, PDF grátis, Gumroad e consultoria.