LEAKCADE

Password reset security: how to build a forgot-password flow that holds up

Updated 8 min read

Password reset security starts with treating a reset link like a temporary password. Make the token random, store only its hash (a one-way fingerprint that can't be turned back into the token), expire it in minutes and burn it after one use. Return the same message whether the email exists or not. If you use Supabase or Clerk, skip the custom flow and use their built-in reset.

QUICK CHECKLIST

  • Same reply for every email, every time
  • Random token, store only its hash
  • Expire in minutes, burn on first use
  • Reset step must verify the token
  • Rate limit per account, no referrer leaks
  • Delete all sessions after a reset

Why password reset security matters

A reset link does the same job as a password. Whoever holds it can take the account, often with no old password and no two-factor authentication (2FA) prompt. So the token needs the same care as a password, and the endpoints around it must give nothing away.

This is also a feature where custom code goes wrong easily. OWASP's Forgot Password cheat sheet calls it a common source of vulnerabilities, and OWASP Top 10:2025 A07 lists weak password recovery as CWE-640 (CWE is the standard catalog of software weakness types). A prompt like "add forgot password" gets you a flow that works when everyone behaves.

Weak reset flow vs safe reset flowTwo rows of four steps. The top row shows a leaky form, a guessable token, a link that never expires and a reset step that trusts a user id. The bottom row shows the safe version of each step.COMMON MISTAKE1Form says: nosuch account2Token isbase64(userId +time)3Link neverexpires,reusable4Reset steptrusts a userIdSAFER FLOW1Same reply forevery email2Random token,only hash stored315 min expiry,one use4Reset step needsthe tokenCOMMON MISTAKE1Form says: no such account2Token is base64(userId + time)3Link never expires, reusable4Reset step trusts a userIdSAFER FLOW1Same reply for every email2Random token, only hash stored315 min expiry, one use4Reset step needs the token
Each weak step has a direct fix.

Common password reset vulnerabilities and how attackers use them

Common mistakes and what each one lets an attacker do:

What a weak flow looks like in code, and the fixed version

This is the request step. It leaks whether the email exists, builds the token from guessable parts and stores it in plain text with no expiry.

BEFORELeaky and guessable
// app/api/forgot/route.ts
export async function POST(req: Request) {
  const { email } = await req.json()
  const user = await db.user.findUnique({ where: { email } })
  if (!user) {
    return Response.json({ error: 'No account with that email' }, { status: 404 })
  }
  const token = Buffer.from(`${user.id}:${Date.now()}`).toString('base64')
  await db.user.update({ where: { id: user.id }, data: { resetToken: token } })
  await sendEmail(email, `https://myapp.com/reset?token=${token}`)
  return Response.json({ ok: true })
}
AFTERRandom, hashed, short-lived
// app/api/forgot/route.ts
import { randomBytes, createHash } from 'node:crypto'

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

export async function POST(req: Request) {
  const { email } = await req.json()
  const user = typeof email === 'string'
    ? await db.user.findUnique({ where: { email } })
    : null
  if (user) {
    const token = randomBytes(32).toString('base64url')
    await db.passwordReset.deleteMany({ where: { userId: user.id } })
    await db.passwordReset.create({
      data: {
        userId: user.id,
        tokenHash: sha256(token),
        expiresAt: new Date(Date.now() + 15 * 60 * 1000),
      },
    })
    void queueResetEmail(user.email, `${process.env.APP_URL}/reset-password?token=${token}`)
  }
  return Response.json({ message: 'If that email has an account, we sent a link.' })
}
The fixed version answers every email the same way and keeps only a hash of the token.

The database stores only the SHA-256 hash of the token, so a leaked table doesn't hand out live links. SHA-256 is fine here because the token is already 256 random bits.

The link is built from an environment variable because the Host header comes from the request and an attacker can change it. OWASP calls this Host Header Injection. The email goes to a background queue. To even out timing fully, enqueue only the address and do the lookup and token writes in the worker.

Now the reset step. The bad version trusts a user id sent by the browser.

BEFORENo token check
// app/api/reset/route.ts
export async function POST(req: Request) {
  const { userId, password } = await req.json()
  await db.user.update({
    where: { id: userId },
    data: { passwordHash: await hashPassword(password) },
  })
  return Response.json({ ok: true })
}
AFTERToken, expiry, single use
// app/api/reset/route.ts
import { createHash } from 'node:crypto'

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

export async function POST(req: Request) {
  const { token, password } = await req.json()
  const bad = () => Response.json({ error: 'Link is invalid or expired' }, { status: 400 })
  if (typeof token !== 'string' || typeof password !== 'string') return bad()
  // apply the same password policy as signup here

  const row = await db.passwordReset.findUnique({ where: { tokenHash: sha256(token) } })
  if (!row || row.usedAt || row.expiresAt < new Date()) return bad()

  // only one request can claim the token
  const claimed = await db.passwordReset.updateMany({
    where: { id: row.id, usedAt: null },
    data: { usedAt: new Date() },
  })
  if (claimed.count === 0) return bad()

  await db.user.update({
    where: { id: row.userId },
    data: { passwordHash: await hashPassword(password) },
  })
  await db.session.deleteMany({ where: { userId: row.userId } })
  return Response.json({ ok: true })
}
The user id comes from the stored token row, never from the request.

The fixed version looks the token up by its hash and checks expiry and use. A single conditional update marks it used, so two simultaneous requests can't both win. Only then does it change the password and delete the user's sessions. In schema.prisma, declare tokenHash as String @unique for findUnique to work.

This assumes database sessions. With JWT sessions, add a sessionVersion column, increment it here, embed it in the JWT and reject mismatched versions on every request.

How to fix password reset security, step by step

Work through these in order.

  1. Generate a random token

    Use crypto.randomBytes with at least 16 bytes (128 bits). Never build a token from a user id, email or timestamp. Prefer a plain random string over a JWT (a signed token that carries data), because OWASP says JWTs can add vulnerabilities of their own.

  2. Store the hash, set an expiry, mark use

    Save the SHA-256 of the token with userId, expiresAt and usedAt. 15 minutes is a reasonable token expiry to start with. Delete earlier tokens for that user when you issue a new one.

  3. Make every response look the same

    Return one message and one status for any email. Send the email from a queue. Build the link from a hard-coded base URL over HTTPS.

  4. Limit abuse and stop referrer leaks

    Rate limit per account and per IP. The login rate limiting guide shows the pattern. Then set the policy on the reset page:

    // next.config.ts
    import type { NextConfig } from 'next'
    
    const nextConfig: NextConfig = {
      async headers() {
        return [{
          source: '/reset-password',
          headers: [{ key: 'Referrer-Policy', value: 'no-referrer' }],
        }]
      },
    }
    export default nextConfig

    Other headers are covered in security headers for Next.js.

  5. Burn the token and end old sessions

    Mark the token used before changing the password. End every session for that user. Then email them that the password changed (never the password itself). OWASP advises sending users to the normal login page afterwards instead of logging them in automatically.

  6. Or use a provider flow

    If you're on Supabase Auth or Clerk, delete your custom route and use the provider's reset method.

Use the Supabase or Clerk reset flow instead

On Supabase Auth you don't need to build any of this. Per the Supabase docs, resetPasswordForEmail() doesn't reveal whether an account exists. When no user matches the address, no email goes out, and the call still returns without an error.

await supabase.auth.resetPasswordForEmail(email, {
  redirectTo: 'https://yourapp.com/account/update-password',
})

// on the update-password page, once the user has a session
await supabase.auth.updateUser({ password: newPassword })

The redirectTo URL must be on your Redirect URLs list in the Supabase dashboard. With SSR (the PKCE flow), the link arrives with a code: call supabase.auth.exchangeCodeForSession(code) first, then updateUser.

Check the current docs for what happens to existing sessions after a password change, then confirm it with two browsers. For server-side user checks, read Supabase getSession vs getUser.

Clerk ships reset flows too, which matches OWASP's advice to use a premade, well-trusted system for authentication. See Clerk and NextAuth mistakes for the setup errors that still bite. Delete any custom /api/reset route: a leftover from your first draft is still a live endpoint.

Test it yourself

Run these checks against your own app, locally or on staging, with test accounts. Don't point them at anyone else's site.

First, enumeration. Request a reset for an email that has an account and one that doesn't, then compare the body, status code and time. Timing is noisy, so repeat it a few times.

URL=http://localhost:3000/api/forgot
for email in real-user@example.com nobody-here@example.com; do
  curl -s -w "\n%{http_code} %{time_total}s\n" -X POST "$URL" \
    -H "content-type: application/json" -d "{\"email\":\"$email\"}"
done

Second, the reset step with no token. Use a test user id from your database. This must fail and the password must stay the same.

curl -s -i -X POST http://localhost:3000/api/reset \
  -H "content-type: application/json" \
  -d '{"userId":"USER_ID_FROM_YOUR_DB","password":"NewTestPassw0rd!"}'

Third, rate limiting. Re-run the request 20 times for a real and a fake email. A 429 that appears only for the real account is an enumeration leak.

The rest you can do by hand:

Bad resultGood result
Unknown vs real emailDifferent text, status or timingIdentical response
Open the link twiceSecond reset worksSecond try is rejected
Reset with userId only200 and the password changes400 and nothing changes
Old link after expiryStill worksRejected as expired
20 requests in a row20 emails sent429, or same reply with few emails
Second browser after resetStill logged inLogged out

What each check should show.

Fix prompt you can paste

Give this to Cursor or Claude Code. It asks for findings first, so you see them before it edits anything.

FIX PROMPT
Audit every password reset / forgot password flow in this repo.
Look for routes and actions named forgot, reset, recover, or
resend, and for resetToken, reset_token, Math.random, uuid v1,
base64 of a user id or Date.now(), and any reset endpoint that
takes userId or email plus a new password.

First list each finding with file path and line number, then stop
and wait for my approval. After I approve, fix:
1. Tokens: crypto.randomBytes(32).toString('base64url'). Store only
   the SHA-256 hash with userId, expiresAt (15 minutes) and usedAt.
2. The reset endpoint accepts token + new password only, never a
   userId. Look the token up by hash, reject if expired or used,
   and mark it used with one conditional update before changing
   the password.
3. The forgot endpoint returns the same message and status for any
   email. Send the email from a background job, and keep the work
   equal for known and unknown emails. Build the link from an
   APP_URL env var, never the Host header.
4. Add per-account and per-IP rate limiting. Do not lock accounts.
5. Set Referrer-Policy: no-referrer on the reset page.
6. After a reset, invalidate all sessions for the user (delete
   session rows, or bump a per-user session version if the app
   uses JWT sessions) and send a notification email. Do not log
   the user in automatically.
7. If the app uses Supabase Auth or Clerk, remove the custom flow
   and use the provider's reset method instead.

Explain every change you make and ask before adding a dependency.

To check the rest of the app, scan it with Leakcade.

Further reading

  1. OWASP Forgot Password Cheat Sheet
  2. OWASP Authentication Cheat Sheet
  3. OWASP Top 10:2025 A07 Authentication Failures
  4. Supabase docs: Password-based Auth

FAQ

How long should a password reset token last?

OWASP says tokens should expire after an appropriate period but gives no fixed number. 15 minutes is a reasonable starting point, and users can always request a new link.

Should a password reset link be single use?

Yes. OWASP says reset tokens should be invalidated after they are used. Mark the token used with one conditional database update before you change the password, so two simultaneous requests can't both succeed.

What is user enumeration in a password reset form?

It's when the form reveals whether an email has an account, through different messages, status codes or response times. Attackers use that to build a list of your users. Return one identical message for every address.

Is it safe to put the reset token in the URL?

Yes, with safeguards. OWASP lists URL tokens as the simplest approach. Use HTTPS, a short expiry, hashed storage and single use. Set Referrer-Policy: no-referrer on the reset page and keep third-party scripts off it.

Should I log users out everywhere after a password reset?

Yes. OWASP says to either ask the user or invalidate all existing sessions automatically. Otherwise an attacker who is already logged in keeps access after the victim changes the password.

Want a second pair of eyes?

We review your whole repo and write a fix prompt for every finding. $19, report within an hour.

Check my repo

Leakcade is an automated check. It can miss vulnerabilities and is not a substitute for a professional security audit or pentest. Provided as is. You stay responsible for your code. Where the law allows, our liability is limited to what you paid ($19). Terms

Patch Notes by Leakcade

A short weekly email: what changed in AI coding tools, one real security mistake, one prompt you can reuse.