Next.js Password Reset Security: The Four Bugs Your AI Ships by Default (With Code)

July 30, 2026 · 2477 words

"Add forgot password to my app" is one of the last prompts you type before launch. Cursor or Claude Code answers in about fifteen seconds: a forgot-password route, a reset-password route, an email, done. You click through it yourself, the email arrives, the new password works. It looks finished.

It isn't. Next.js password reset security is where AI-generated auth quietly falls apart, because the code works perfectly while quietly handing a stranger everything they need. This is not hypothetical: in September 2025 the popular open-source AI-workflow tool Flowise shipped a forgot-password endpoint that returned the reset token straight back in the API response. Anyone who knew your email address could take over your account. It scored 9.8 out of 10 on the CVSS severity scale (GHSA-wgpv-6j63-x5ph / CVE-2025-58434).

That's a real, funded engineering team. Your AI assistant writes the same shape of code, and this guide shows you the exact fix — verified against the current Next.js Route Handlers docs and the OWASP Forgot Password Cheat Sheet.

Plain-language glossary

Three terms, no security background needed.

  • Reset token — the long random string in the ?token=... part of the reset link you email. Whoever holds it can change that account's password. It is a temporary key to the account.
  • User enumeration — an attacker discovering which email addresses have accounts on your site, by watching how your app replies differently to "known" and "unknown" emails. That list becomes the target list for phishing and password-stuffing.
  • Hashing — running a value through a one-way function so the stored version can be checked but not read back. Storing a hash of a token means a leaked database doesn't hand over working reset links.

Diagram of a secure Next.js password reset flow showing a hashed single-use reset token stored in the database and delivered only by email, contrasted with the insecure AI-generated version that returns the token in the API response

The code your AI wrote

Here is the forgot-password route almost every AI assistant produces. Read it before the explanation — you may recognise it word for word.

// app/api/auth/forgot-password/route.ts — INSECURE, do not ship
import { randomUUID } from 'node:crypto'
import { prisma } from '@/lib/prisma'
import { sendEmail } from '@/lib/email'

export async function POST(request: Request) {
  const { email } = await request.json()

  const user = await prisma.user.findUnique({ where: { email } })
  if (!user) {
    return Response.json({ error: 'No account with that email' }, { status: 404 })
  }

  const token = randomUUID()
  await prisma.user.update({
    where: { id: user.id },
    data: { resetToken: token },
  })

  await sendEmail(email, `Reset here: ${process.env.APP_URL}/reset-password?token=${token}`)

  // "helpful" for debugging — and catastrophic in production
  return Response.json({ message: 'Reset email sent', resetToken: token })
}

Four separate problems, in plain language.

1. It tells strangers who has an account. The 404 No account with that email reply is a yes/no oracle. A script can feed it ten thousand addresses and keep the ones that come back 404-free. That's a verified customer list, handed out for free.

2. It returns the token in the response body. This is the exact Flowise bug. The attacker never needs to read the victim's inbox — the API hands over the key. Anything you return from a route handler is visible to whoever called it.

3. The token is stored in plain text and never expires. randomUUID() is random enough (Node generates it from a cryptographically secure source), but the row sits in your users table forever. A read-only database leak, a leaked backup, a logging integration that dumps rows — any of these become account takeover months later.

4. It's single-column, so it's never single-use. Nothing marks the token as consumed. The link in an email from six months ago still works, and old sessions on stolen devices stay logged in after the reset.

RiskWhat an attacker can actually doThe fix
User enumerationBuild a verified list of your users' emails for phishingReturn the same generic message for every input
Token in API responseTake over any account knowing only the emailNever return the token; email is the only channel
Plaintext token in DBTurn a read-only DB leak into account takeoverStore sha256(token), never the token itself
No expiryOld links from any past inbox still workShort expiresAt (15–60 minutes)
No single-use flagReuse a link that was already usedusedAt timestamp, checked and set in a transaction
Sessions survive resetAttacker stays logged in after the victim recoversDelete all sessions for that user on reset
No rate limitFlood an inbox, or brute-force tokensStrict limiter on both reset routes

Risk matrix for Next.js password reset security showing user enumeration, token in the API response, plaintext tokens, missing expiry, token replay, surviving sessions, and no rate limit, each with its impact and fix

The fix, step by step

Step 1: Give reset tokens their own table

Reset tokens do not belong on the users row. They need an expiry, a used-at marker, and the ability to be deleted in bulk.

// prisma/schema.prisma
model PasswordResetToken {
  id        String    @id @default(cuid())
  tokenHash String    @unique   // sha256 of the token — never the token itself
  userId    String
  expiresAt DateTime
  usedAt    DateTime?
  createdAt DateTime  @default(now())
  user      User      @relation(fields: [userId], references: [id], onDelete: Cascade)

  @@index([userId])
}

// And add the other side of the relation to your existing User model,
// or `prisma generate` will fail with "missing an opposite relation field":
// model User {
//   ...
//   passwordResetTokens PasswordResetToken[]
// }

Step 2: The forgot-password route that says nothing

// app/api/auth/forgot-password/route.ts — SECURE
import { createHash, randomBytes } from 'node:crypto'
import { z } from 'zod'
import { prisma } from '@/lib/prisma'
import { sendPasswordResetEmail } from '@/lib/email'

export const runtime = 'nodejs' // node:crypto is not available on the edge runtime

// Zod 3 syntax below. On Zod 4 this becomes z.object({ email: z.email().max(254) }).
const schema = z.object({ email: z.string().email().max(254) })
const TOKEN_TTL_MS = 30 * 60 * 1000 // 30 minutes

// Same reply, every single time. This one line kills user enumeration.
const GENERIC = {
  message: 'If an account exists for that email, we have sent a reset link.',
}

const sha256 = (value: string) => createHash('sha256').update(value).digest('hex')

export async function POST(request: Request) {
  const parsed = schema.safeParse(await request.json().catch(() => null))
  // Even malformed input gets the generic 200 — no signal leaks out.
  if (!parsed.success) return Response.json(GENERIC, { status: 200 })

  const email = parsed.data.email.toLowerCase()
  const user = await prisma.user.findUnique({ where: { email } })

  if (user) {
    const token = randomBytes(32).toString('hex') // 256 bits of entropy
    await prisma.passwordResetToken.create({
      data: {
        tokenHash: sha256(token),
        userId: user.id,
        expiresAt: new Date(Date.now() + TOKEN_TTL_MS),
      },
    })

    // Build the URL from a trusted env var — never from the request Host header,
    // which an attacker can forge to point the link at their own domain.
    const url = `${process.env.APP_URL}/reset-password?token=${token}`
    await sendPasswordResetEmail(email, url)
  }

  return Response.json(GENERIC, { status: 200 })
}

The important part is what's missing: no 404, no token in the response, no plaintext token in the database. The raw token exists in exactly two places — the email, and the URL the user clicks.

Step 3: The reset route that consumes the token

// app/api/auth/reset-password/route.ts — SECURE
import { createHash } from 'node:crypto'
import { z } from 'zod'
import { hash } from 'bcryptjs'
import { prisma } from '@/lib/prisma'

export const runtime = 'nodejs'

const schema = z.object({
  token: z.string().length(64),        // 32 random bytes as hex
  // bcrypt silently ignores anything past 72 *bytes*; .max(72) counts characters,
  // so it's a close-enough guard rail, not an exact one.
  password: z.string().min(12).max(72),
})

const sha256 = (value: string) => createHash('sha256').update(value).digest('hex')

export async function POST(request: Request) {
  const parsed = schema.safeParse(await request.json().catch(() => null))
  if (!parsed.success) {
    return Response.json({ error: 'Invalid request.' }, { status: 400 })
  }

  const { token, password } = parsed.data
  const record = await prisma.passwordResetToken.findUnique({
    where: { tokenHash: sha256(token) },
  })

  // One message for "wrong", "used" and "expired" — don't help attackers sort them.
  if (!record || record.usedAt || record.expiresAt < new Date()) {
    return Response.json(
      { error: 'This reset link is invalid or has expired.' },
      { status: 400 },
    )
  }

  const passwordHash = await hash(password, 12) // OWASP floor for bcrypt is 10

  await prisma.$transaction([
    prisma.user.update({
      where: { id: record.userId },
      data: { passwordHash },
    }),
    // Burn this token...
    prisma.passwordResetToken.update({
      where: { id: record.id },
      data: { usedAt: new Date() },
    }),
    // ...and every other outstanding one for this user.
    prisma.passwordResetToken.deleteMany({
      where: { userId: record.userId, usedAt: null },
    }),
    // Kick out anyone already signed in as this user, including the attacker.
    prisma.session.deleteMany({ where: { userId: record.userId } }),
  ])

  return Response.json({ message: 'Password updated. Please sign in.' })
}

Three details worth pausing on. The transaction means the password change and the token burn either both happen or neither does. Deleting sessions is the step almost no AI writes, and it's the one that actually evicts an attacker who already got in. And don't log the user in automatically — OWASP recommends sending them through your normal login, because auto-login adds session-handling code that tends to grow its own bugs.

On hashing: the OWASP Password Storage Cheat Sheet now prefers Argon2id for new applications, describing bcrypt as suitable for legacy systems at a work factor of 10 or higher (12 above gives you headroom). bcryptjs is used above because it's what most AI-generated Next.js auth already imports — if you're starting fresh, reach for an Argon2id implementation instead.

Step 4: Rate limit both routes

Without a limiter, an attacker can send a thousand reset emails to one victim's inbox, or grind through token guesses. Both reset routes belong on your strictest limiter — see our guide to rate limiting an AI-built Next.js API for the Redis-backed version that survives serverless.

How to test the fix

Run these against your deployed URL, not localhost.

# 1. A real user and a fake user must produce identical status codes AND bodies.
ask() {
  curl -s -w "\nHTTP %{http_code}\n" -X POST https://yourapp.com/api/auth/forgot-password \
    -H "Content-Type: application/json" -d "{\"email\":\"$1\"}"
}
diff <(ask [email protected]) <(ask [email protected]) \
  && echo "identical — good" || echo "LEAK — responses differ"
# 2. The response must not contain a token. Grep for it.
curl -s -X POST https://yourapp.com/api/auth/forgot-password \
  -H "Content-Type: application/json" -d '{"email":"[email protected]"}' \
  | grep -iE "token|expir" && echo "LEAK — fix this" || echo "clean"
# 3. Reuse a token you already used. It must fail.
curl -s -X POST https://yourapp.com/api/auth/reset-password \
  -H "Content-Type: application/json" \
  -d '{"token":"<the-token-you-just-used>","password":"AnotherPassword123!"}'
# Expect: {"error":"This reset link is invalid or has expired."}

Then one manual check: open your database and look at the reset token row. If you can read a value that also appears in the emailed link, you are still storing plaintext tokens.

One nuance the curl tests won't catch: an unknown email skips the database write and the email send, so it may come back measurably faster. OWASP calls this out — a determined attacker can enumerate on timing alone. Queue the email send as a background job so both paths return at the same speed.

Ask your AI to fix it

Paste this into Cursor, Claude Code, or Copilot as-is.

Audit and rewrite my Next.js App Router password reset flow to be secure.
1. Move reset tokens off the users table into a PasswordResetToken model with
   tokenHash (unique), userId, expiresAt, usedAt, createdAt.
2. In /api/auth/forgot-password: validate the body with Zod, and return the SAME
   generic 200 JSON message for valid emails, unknown emails, and malformed input.
   Never return the token or any user data. Generate it with
   randomBytes(32).toString('hex') and store only createHash('sha256') of it.
   Set expiresAt to 30 minutes. Build the reset URL from process.env.APP_URL,
   never from the request Host header.
3. In /api/auth/reset-password: look the token up by its sha256 hash, reject if
   missing, used, or expired with one identical error message. Hash the new password
   (Argon2id preferred; if bcrypt, work factor 12 — OWASP's floor is 10), then in one transaction:
   update the password, set usedAt, delete the user's other unused tokens, and delete
   all of that user's sessions. Do not auto-login the user.
4. Add export const runtime = 'nodejs' to both routes.
5. Apply my existing rate limiter to both routes at a strict limit.
Show me the diff and the Prisma migration before applying anything.

Password reset security checklist

Next.js password reset security checklist card listing generic responses, hashed single-use tokens, short expiry, session invalidation, trusted reset URL host, and rate limiting on both routes

  • Forgot-password returns one identical message for every input, including invalid ones
  • No token, user ID, email, or account status ever appears in a response body
  • Token generated with randomBytes(32), not Math.random() or a counter
  • Database stores sha256(token) only — never the raw token
  • expiresAt set to 15–60 minutes and actually checked
  • usedAt set inside the same transaction as the password update
  • All other outstanding tokens for that user deleted on success
  • All existing sessions for that user deleted on success
  • Reset URL built from APP_URL, never the request Host header
  • User is sent to normal login, not auto-logged-in
  • Both routes behind a strict rate limiter
  • A "your password was changed" notification email is sent (never containing the password)
  • Verified with the curl checks above against production, not localhost

Password reset is the back door to every account in your app, which makes it the highest-leverage hour of security work you can do — the same server-side-first thinking behind our vibe coding security pillar guide. If you'd rather not build auth yourself at all, our secure authentication tools roundup covers libraries that ship this flow correctly out of the box, and the httpOnly cookie guide covers where the session goes once the user is back in.

Shipped a vibe-coded app with a forgot-password flow you've never actually attacked? I do hands-on code-security audits of AI-built apps — I run the enumeration and token-reuse scripts against your live routes and send back the specific fixes. Email [email protected] with your stack and I'll take a look.

Key Takeaway

An AI-generated password reset flow works on the happy path and fails on every adversarial one. Four changes cover almost all of it: one generic response for everybody, a hashed token in its own table, a short expiry with a used-at flag, and deleting every session when the password changes. Thirty minutes of work standing between a stranger with your users' email addresses and their accounts.