Use Cases (Aktualisiert: 1.6.2026)

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.

Sicherer Passwort-Reset mit Claude Code: Next.js, Prisma und Token

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]
ThemaEntscheidungGrund
Konto-ExistenzGleiche Antwort für bekannte und unbekannte E-MailsVerhindert Enumeration
Tokencrypto.randomBytes(32).toString("base64url")Schwer zu erraten
SpeicherungNur SHA-256-HashDB-Leak liefert keinen nutzbaren Link
Ablaufzeit30 MinutenKurzes Risiko-Fenster
NutzungEinmaligStoppt Wiederverwendung aus Logs oder Verlauf
Rate LimitNach IP und normalisierter E-MailReduziert Spam und Brute Force
E-MailNie das Passwort sendenE-Mail ist kein Tresor
Reset-Seitenoindex, Referrer-Policy: no-referrerVerhindert Indexierung und Referer-Leak
Nach ResetSessions widerrufen, kein Auto-LoginEntfernt gestohlene Sessions
MFANicht in diesem Flow zurücksetzenMFA-Recovery braucht einen eigenen Prozess

Arbeitsgranularität für Claude Code

Teile den Auftrag so, dass jedes Ergebnis prüfbar bleibt.

  1. Prisma-Schema um PasswordResetToken, Session und AuditLog erweitern.
  2. Service-Schicht mit Node crypto, Hash, Ablauf, einmaliger Nutzung und Rate Limit bauen.
  3. Next.js-App-Router-Routen für request und confirm hinzufügen.
  4. E-Mail-Template, Reset-Seite, Referrer-Policy, X-Robots-Tag und Cache-Header ergänzen.
  5. 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.

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

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.

Masa

Über den Autor

Masa

Engineer für praktische Claude-Code-Workflows und Team-Einführung.