Sicherer Passwort-Reset mit Claude Code: Next.js, Prisma und Token
So implementierst du sicheren Passwort-Reset mit Claude Code: Zufallstoken, Hash-Speicherung, Rate Limits, E-Mail, Sessions, MFA und Audit.
Ein Passwort-Reset ist keine harmlose Hilfeseite, sondern ein Weg zur Kontowiederherstellung. Wenn dieser Weg schwächer ist als der Login, nutzen Angreifer genau diesen Weg. Typische Fehler sind sichtbare Hinweise auf existierende E-Mail-Adressen, rohe Tokens in der Datenbank, nie ablaufende Links, mehrfach nutzbare Tokens, aktive alte Sessions nach dem Reset oder ein MFA-Reset im gleichen Ablauf.
Claude Code kann diese Funktion sauber umsetzen, wenn die Aufgabe klar formuliert ist. Die Anweisung sollte nicht “baue eine Passwort-vergessen-Seite” lauten, sondern konkrete Akzeptanzkriterien enthalten: keine User Enumeration, zufällige Tokens, Hash-Speicherung, kurze Laufzeit, einmalige Nutzung, Rate Limit, E-Mail-Template, Referrer-Policy, noindex, Session-Invalidierung, Audit-Logs und getrennte MFA-Wiederherstellung.
Die Sicherheitsbasis sind OWASP Forgot Password Cheat Sheet, Authentication Cheat Sheet und Testing for Weak Password Change or Reset Functionalities. Für den Claude-Code-Kontext passen zusätzlich Security Best Practices und Environment Management.
Der sichere Ablauf
Das Reset-Token ist ein temporärer Schlüssel. Der Nutzer bekommt ihn per E-Mail, aber die Datenbank speichert nur den Hash. So wird ein Datenbank- oder Backup-Leak nicht automatisch zu gültigen Reset-Links.
flowchart TD
A[Nutzer gibt E-Mail ein] --> B[Immer gleiche Antwort zurückgeben]
B --> C{Rate Limit ok}
C -- nein --> Z[Existenz des Kontos nicht verraten]
C -- ja --> D{Nutzer existiert}
D -- nein --> Z
D -- ja --> E[Zufallstoken mit Node crypto erzeugen]
E --> F[Nur SHA-256 Token-Hash speichern]
F --> G[HTTPS-Link per E-Mail senden]
G --> H[Reset-Seite mit noindex und Referrer-Policy]
H --> I[Neues Passwort senden]
I --> J[Token einmalig in Transaktion verbrauchen]
J --> K[Passwort hashen und Sessions widerrufen]
K --> L[Audit-Log schreiben und Login verlangen]
| Thema | Entscheidung | Grund |
|---|---|---|
| Konto-Existenz | Gleiche Antwort für bekannte und unbekannte E-Mails | Verhindert Enumeration |
| Token | crypto.randomBytes(32).toString("base64url") | Schwer zu erraten |
| Speicherung | Nur SHA-256-Hash | DB-Leak liefert keinen nutzbaren Link |
| Ablaufzeit | 30 Minuten | Kurzes Risiko-Fenster |
| Nutzung | Einmalig | Stoppt Wiederverwendung aus Logs oder Verlauf |
| Rate Limit | Nach IP und normalisierter E-Mail | Reduziert Spam und Brute Force |
| Nie das Passwort senden | E-Mail ist kein Tresor | |
| Reset-Seite | noindex, Referrer-Policy: no-referrer | Verhindert Indexierung und Referer-Leak |
| Nach Reset | Sessions widerrufen, kein Auto-Login | Entfernt gestohlene Sessions |
| MFA | Nicht in diesem Flow zurücksetzen | MFA-Recovery braucht einen eigenen Prozess |
Arbeitsgranularität für Claude Code
Teile den Auftrag so, dass jedes Ergebnis prüfbar bleibt.
- Prisma-Schema um
PasswordResetToken,SessionundAuditLogerweitern. - Service-Schicht mit Node crypto, Hash, Ablauf, einmaliger Nutzung und Rate Limit bauen.
- Next.js-App-Router-Routen für request und confirm hinzufügen.
- E-Mail-Template, Reset-Seite,
Referrer-Policy,X-Robots-Tagund Cache-Header ergänzen. - Tests für Nicht-Enumeration, abgelaufene Tokens, wiederverwendete Tokens und Session-Revoke schreiben.
Ein brauchbarer Prompt:
Implementiere Passwort-Reset in einer Next.js App Router + TypeScript + Prisma App.
Constraints:
- Existierende und nicht existierende E-Mails liefern gleiches JSON und ähnliche Antwortzeit
- Reset token mit Node crypto, mindestens 32 bytes, DB speichert nur SHA-256 hash
- Token läuft nach 30 Minuten ab und ist nur einmal nutzbar
- Request API hat Rate Limit nach IP und normalisierter E-Mail
- Reset-Seite setzt noindex und Referrer-Policy no-referrer
- Nach Erfolg alle Sessions widerrufen und nicht automatisch einloggen
- Kein MFA reset in diesem Flow; MFA recovery bleibt getrennt
- Keine rohen Tokens, Reset URLs oder Passwörter loggen
Ändere nur die nötigen Auth-, Prisma-, E-Mail- und Testdateien.
Prisma-Schema
Dieses Schema ist bewusst minimal. Wenn User oder Session bereits existieren, füge nur die fehlenden Felder hinzu.
// 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])
}
Service-Code
Der folgende Code enthält Token-Generierung, Hash-Speicherung, einfaches Rate Limit, E-Mail-Template, einmalige Token-Nutzung, Passwort-Hashing, Session-Revoke und Audit-Log. Für Produktion sollte das Map durch Redis oder einen gemeinsamen Store ersetzt werden.
// 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-Routen und Header
Die Request-Route gibt immer denselben Body zurück. Die Confirm-Route verrät nur, dass ein Link ungültig oder abgelaufen ist.
// 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;
Praxisfälle
Erster Fall: Ein normaler SaaS-Nutzer vergisst sein Passwort. Kurze Laufzeit, einmalige Nutzung und Session-Revoke schützen vor alten Geräten, gemeinsam genutzten Browsern und gestohlenen Sessions.
Zweiter Fall: Ein Admin-Konto setzt das Passwort zurück. Danach muss der normale Login inklusive MFA folgen. Verlorene MFA-Geräte gehören in einen getrennten Ablauf wie im Leitfaden zur Zwei-Faktor-Authentifizierung.
Dritter Fall: Der Support prüft “Ich habe keine E-Mail erhalten”. Audit-Logs zeigen Zeit, IP, User-Agent und Mailstatus, aber niemals das rohe Token.
Vierter Fall: Ein B2B-Tenant bekommt Reset-Mail-Flooding. Rate Limits nach IP und E-Mail liefern früh ein Signal.
Fehler und Review-Punkte
Blockiere in der Review jede Meldung wie “E-Mail nicht gefunden”. Auch Timing-Unterschiede können dieselbe Information verraten.
Blockiere rohe Tokens in DB, Logs, Monitoring und Support-Tools. Leserechte würden sonst zu Kontoübernahme-Rechten.
Prüfe außerdem Ablaufzeit, usedAt, Session-Revoke, kein Auto-Login, feste HTTPS APP_ORIGIN, Header auf der Reset-Seite und keine MFA-Änderungen im selben Diff.
npm install @prisma/client bcryptjs zod
npx prisma migrate dev --name add_password_reset
npm run lint
npm test
Fazit
Passwort-Reset ist ein kleines Authentifizierungssystem. Mit Claude Code sollte er als Sicherheitsfeature beauftragt werden: keine Enumeration, zufällige Tokens, Hash-Speicherung, kurze Laufzeit, einmalige Nutzung, Rate Limit, sichere E-Mail, noindex, Referrer-Policy, Session-Revoke, Audit-Logs und getrenntes MFA-Recovery.
ClaudeCodeLab unterstützt bei Review, Training und Implementierung solcher Auth-Flows mit Claude Code. Mit Login-Ablauf, Session-Modell, E-Mail-Anbieter und MFA-Policy lässt sich schnell bestimmen, was generiert werden kann und was menschliche Review braucht.
Beim praktischen Testen prüfe zuerst vier Dinge: echte und falsche E-Mail liefern dieselbe Antwort, die DB enthält keine rohen Tokens, abgelaufene und benutzte Tokens schlagen fehl, und alte Sessions können keine geschützten APIs mehr aufrufen. Masa beginnt genau mit diesen Checks, bevor daraus automatisierte Tests werden.
Kostenloses PDF: Claude-Code-Cheatsheet
E-Mail eintragen und eine Seite mit Befehlen, Review-Gewohnheiten und sicheren Workflows herunterladen.
Wir schützen Ihre Daten und senden keinen Spam.
Über den Autor
Masa
Engineer für praktische Claude-Code-Workflows und Team-Einführung.
Ähnliche Artikel
Claude Code Workflow von Obsidian zu CLAUDE.md
Obsidian-Arbeitsnotizen in CLAUDE.md-Betriebsnotizen verwandeln und Kontext nicht ständig neu erklären.
Claude Code Revenue CTA Routing: Artikel zu PDF, Gumroad und Beratung führen
Ein Claude-Code-Ablauf, der Leser nach Absicht zu Gratis-PDF, Gumroad oder Beratung führt.
Claude-Code-Team-Handoff-Regeln: Belege, Berechtigungen, Rollback und Umsatzpfade
Ein praktisches Claude-Code-Handoff für Review-Belege, Berechtigungen, Rollback, Gratis-PDF, Gumroad und Beratung.