Use Cases (Updated: 6/1/2026)

Secure Password Reset with Claude Code: Next.js, Prisma, and Crypto

Build a secure password reset flow with Claude Code: random tokens, hashed storage, rate limits, email, sessions, MFA, and audit logs.

Secure Password Reset with Claude Code: Next.js, Prisma, and Crypto

Password reset looks like a convenience feature, but it is really an account recovery path. If it is weaker than your login flow, attackers will use it instead of the login flow. The usual failures are quiet: revealing whether an email exists, storing raw reset tokens, allowing old links to work forever, leaving stolen sessions alive, or treating MFA recovery as if it were the same problem as password recovery.

Claude Code can implement this well if the task is scoped precisely. Do not ask for “a forgot password page” and accept the first working diff. Ask for a complete, reviewable workflow: constant user-facing responses, random tokens, token hash storage, short expiry, one-time use, rate limiting, safe email templates, Referrer-Policy, noindex, session invalidation, audit logs, and a separate policy for MFA reset.

This guide follows the OWASP Forgot Password Cheat Sheet, Authentication Cheat Sheet, and Testing for Weak Password Change or Reset Functionalities. For broader Claude Code security work, pair it with Claude Code security best practices and Claude Code environment management.

The workflow to build

The reset token is a temporary key. The application should send it to the user’s email address, but it should not store the raw key in the database. Store only a hash, expire it quickly, and consume it once.

flowchart TD
  A[User submits email] --> B[Return the same message for every email]
  B --> C{Rate limit ok?}
  C -- no --> Z[Do not reveal whether account exists]
  C -- yes --> D{User exists?}
  D -- no --> Z
  D -- yes --> E[Generate random token with Node crypto]
  E --> F[Store only SHA-256 token hash]
  F --> G[Send HTTPS reset link by email]
  G --> H[Reset page with noindex and Referrer-Policy]
  H --> I[User submits new password]
  I --> J[Claim token once in DB transaction]
  J --> K[Hash password and revoke sessions]
  K --> L[Write audit log and ask user to sign in]

The design checklist is intentionally strict.

AreaDecisionWhy it matters
Account discoverySame response for existing and missing emailsPrevents user enumeration
Token generationcrypto.randomBytes(32).toString("base64url")Produces a hard-to-guess token
Token storageStore SHA-256 token hash onlyA DB leak should not become reset-link access
Expiry30 minutesLimits exposure while allowing email delay
ReuseOne use onlyStops link forwarding, history, and log reuse
Rate limitIP and email keysReduces inbox flooding and token guessing
EmailNever include the passwordEmail is not a secret vault
Reset pagenoindex and Referrer-Policy: no-referrerAvoids indexing and referer leakage
After resetRevoke sessions and do not auto-loginRemoves stolen sessions from circulation
MFADo not reset MFA in this flowMFA recovery needs stronger identity proofing

What to ask Claude Code to do

Security features need small, reviewable tasks. I usually split this work into five requests:

  1. Add PasswordResetToken, Session, and AuditLog to the Prisma schema.
  2. Implement the service layer with Node crypto, token hashing, expiry, one-time use, and rate limits.
  3. Add Next.js App Router API routes for request and confirmation.
  4. Add the email template, reset page, Referrer-Policy, X-Robots-Tag, and cache headers.
  5. Add tests for non-enumeration, expired tokens, reused tokens, and session revocation.

A good Claude Code prompt is concrete:

Implement password reset in a Next.js App Router + TypeScript + Prisma app.
Constraints:
- Existing and non-existing emails must return the same JSON and similar response time
- Generate reset tokens with Node crypto, at least 32 bytes, and store only a SHA-256 hash
- Tokens expire in 30 minutes and are valid for one use only
- Rate limit the request API by IP and normalized email
- Add noindex and Referrer-Policy no-referrer to the reset page
- Revoke all existing sessions after a successful reset and do not auto-login
- Do not implement MFA reset here; keep MFA recovery as a separate flow
- Never log raw tokens, reset URLs, or passwords
Limit changes to the auth, Prisma, email, and test files needed for this feature.

This makes the review easier because each acceptance criterion can be checked in the diff.

Prisma schema

Use this schema as the starter model. If your app already has User or Session, merge the fields instead of duplicating models.

// 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])
}

The important field is tokenHash. A support engineer, backup dump, or SQL log should never reveal a usable reset URL.

Service layer with crypto, rate limits, and email

This starter is copy-pasteable for a small Next.js application. Install @prisma/client, bcryptjs, and zod. For production, replace the in-memory rate limiter with Redis or another shared store.

// 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.",
};

type ResetRequestInput = {
  email: string;
  ip?: string;
  userAgent?: string;
};

type ResetPasswordInput = {
  token: string;
  newPassword: string;
  ip?: string;
  userAgent?: string;
};

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 an HTTPS origin such as https://example.com");
  }
  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;
  expiresMinutes: number;
}) {
  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>Use the button below to set a new password. This link expires in ${input.expiresMinutes} minutes.</p>
      <p><a href="${input.resetUrl}" style="display:inline-block;background:#2563eb;color:#ffffff;padding:12px 18px;border-radius:6px;text-decoration:none">Reset password</a></p>
      <p>If you did not request this email, you can ignore it. Your password has not been changed.</p>
    </div>
  `;

  if (!process.env.RESEND_API_KEY) {
    console.info("Password reset email skipped in local development", {
      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: ResetRequestInput) {
  const startedAt = Date.now();
  const email = normalizeEmail(input.email);
  const ip = input.ip ?? "unknown";
  const userAgent = input.userAgent?.slice(0, 300);

  const limited =
    isLimited(`password-reset:ip:${ip}`, 20, 60 * 60 * 1000) ||
    isLimited(`password-reset:email:${email}`, 5, 15 * 60 * 1000);

  try {
    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 tokenHash = hashToken(token);
      const expiresAt = new Date(Date.now() + RESET_TTL_MINUTES * 60 * 1000);

      await prisma.passwordResetToken.create({
        data: {
          userId: user.id,
          tokenHash,
          expiresAt,
          requestedIp: ip,
          requestedUserAgent: userAgent,
        },
      });

      await prisma.auditLog.create({
        data: {
          userId: user.id,
          event: "password_reset_requested",
          ip,
          userAgent,
          metadata: { expiresAt: expiresAt.toISOString() },
        },
      });

      await sendPasswordResetEmail({
        to: user.email,
        resetUrl: `${appOrigin()}/reset-password?token=${encodeURIComponent(token)}`,
        expiresMinutes: RESET_TTL_MINUTES,
      });
    }

    await padResponse(startedAt);
    return REQUEST_RESPONSE;
  } catch (error) {
    await padResponse(startedAt);
    throw error;
  }
}

function validatePassword(password: string) {
  if (password.length < 12) throw new Error("password_too_short");
  if (password.length > 128) throw new Error("password_too_long");
}

export async function resetPassword(input: ResetPasswordInput) {
  validatePassword(input.newPassword);

  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." };
  });
}

Next.js API routes

The request route returns 202 and the same JSON even for invalid or missing accounts. The browser may still validate the email field, but the API should not become an account discovery endpoint.

// 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, {
    message: "password_mismatch",
    path: ["confirmPassword"],
  });

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(
      { 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,
      ip: clientIp(request),
      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 },
    );
  }
}

Reset page headers

Because the token is in the URL, the reset page should not be indexed, cached, or leak its full URL through the Referer header.

// src/app/reset-password/layout.tsx
import type { Metadata } from "next";

export const metadata: Metadata = {
  title: "Reset password",
  robots: { index: false, follow: false },
  referrer: "no-referrer",
};

export default function ResetPasswordLayout({
  children,
}: {
  children: React.ReactNode;
}) {
  return children;
}
// 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;

Three practical use cases

First, a normal SaaS customer forgets a password and resets it from email. Short expiry, one-time use, and session revocation cover the common risk: an old phone, shared browser, or stolen session remains active after the reset.

Second, an admin account resets a password. The password reset should not disable MFA. For privileged users, require the normal login flow after reset and consider step-up verification before high-risk actions. Use the separate two-factor authentication guide when designing recovery for lost authenticators.

Third, support receives “I never got the email.” Audit logs let support check request time, IP, User-Agent, and email delivery status without seeing a raw token. That is the difference between helpful support tooling and a new insider-risk path.

Fourth, a B2B tenant is attacked with reset-email flooding. Per-IP and per-email rate limits reduce mailbox noise and give you a signal to alert on before the support queue fills up.

Failure cases to catch in review

The most damaging failure is returning “email not found.” It creates an account enumeration API. Timing can leak the same information when missing accounts return immediately and existing accounts perform email or database work.

Raw token storage is the next failure. If a token appears in the database, logs, analytics, error tracking, or support tools, anyone who can read those systems can reset accounts.

Long-lived or reusable tokens are also common. Email gets forwarded, browser history is synced, and screenshots are shared. The token should expire quickly and be marked as used inside a transaction.

Do not build reset URLs from the incoming Host header. Use a fixed HTTPS APP_ORIGIN and keep it under the same review discipline as other secrets and environment variables.

Finally, do not combine password reset and MFA reset. MFA recovery needs a stronger, separate process with identity proofing, notifications, support approval where appropriate, and its own audit events.

Review checklist for Claude Code output

  • Same status and body for existing and non-existing email requests.
  • No raw token, reset URL, or password in logs.
  • SHA-256 token hash stored in the database, not the raw token.
  • expiresAt and usedAt enforced together.
  • Concurrent requests cannot consume the same token twice.
  • Successful reset revokes active sessions and does not auto-login.
  • Reset page sends Referrer-Policy, X-Robots-Tag, and Cache-Control.
  • MFA enrollment or recovery is not changed by this password reset diff.
  • Audit logs include request and completion events without sensitive payloads.

To verify manually, call the request endpoint with a real email and a fake email and compare the status, body, and approximate response time. Then use the same reset token twice: only the first call should succeed. After that, try an old session against a protected API and confirm it is rejected.

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

Closing

A password reset flow is a compact authentication system. Treat it as security-sensitive work, not as a simple form. Claude Code is useful when you give it crisp boundaries: no user enumeration, random tokens, hashed storage, short expiry, one-time use, rate limits, safe email, noindex, referrer protection, session revocation, audit logs, and no MFA shortcut.

ClaudeCodeLab can help review, train, or implement this kind of authentication workflow with Claude Code. Bring your current login flow, session model, email provider, and MFA policy; that is enough to identify which parts can be generated safely and which parts need human review.

When you try the implementation from this article, check four things before trusting it: real and fake emails produce the same response, the database never contains raw tokens, expired and used tokens fail, and old sessions stop working after reset. Those are the same checks Masa starts with before turning the behavior into automated tests.

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

Free PDF: Claude Code Cheatsheet

Enter your email and download the one-page Claude Code cheatsheet for commands, review habits, and safe workflows.

We handle your data with care and never send spam.

Level up your Claude Code workflow

Start with the free PDF, use Gumroad guides when you need repeatable workflows, and book consultation when rollout or revenue paths need human judgment.

Masa

About the Author

Masa

Engineer focused on practical Claude Code workflows. Runs claudecode-lab.com, a 10-language technical media site.