Password reset security: how to build a forgot-password flow that holds up
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.
Common password reset vulnerabilities and how attackers use them
Common mistakes and what each one lets an attacker do:
- Different messages for known and unknown emails. "No account with that email" turns your form into a lookup tool. This is user enumeration, and a slower response for real accounts leaks the same thing.
- A guessable forgot password token. A token built from a user id and a timestamp, or from
Math.random(), can be rebuilt without seeing the inbox. - Tokens that never expire or work twice. An old or forwarded email becomes a permanent key.
- A reset step that takes a user id and a new password and never checks a token. Anyone can set anyone's password. This password reset vulnerability is an IDOR (insecure direct object reference: the server trusts an id sent by the browser).
- A token in the URL that leaks. If the reset page loads third-party resources, the token can leak through the
Refererheader. Modern browsers trim cross-site referrers to the origin by default, but older browsers, a looser site-wide policy or your own logs can still expose the full URL. OWASP says to set a Referrer Policy ofno-referreron that page. - No rate limit. Attackers can flood an inbox or hammer the token endpoint with guesses.
- Sessions that survive the reset. An attacker who is already logged in stays logged in.
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.
// 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 })
}// 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 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.
// 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 })
}// 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 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.
Generate a random token
Use
crypto.randomByteswith 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.Store the hash, set an expiry, mark use
Save the SHA-256 of the token with
userId,expiresAtandusedAt. 15 minutes is a reasonable token expiry to start with. Delete earlier tokens for that user when you issue a new one.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.
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 nextConfigOther headers are covered in security headers for Next.js.
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.
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\"}"
doneSecond, 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:
- Use a reset link once, then open the same link again.
- Set
expiresAtto the past in the database and try the link. - Log in on two browsers, reset the password in one, then reload the other.
- Run
curl -sIon the reset page and look forReferrer-Policy: no-referrer.
| Bad result | Good result | |
|---|---|---|
| Unknown vs real email | Different text, status or timing | Identical response |
| Open the link twice | Second reset works | Second try is rejected |
| Reset with userId only | 200 and the password changes | 400 and nothing changes |
| Old link after expiry | Still works | Rejected as expired |
| 20 requests in a row | 20 emails sent | 429, or same reply with few emails |
| Second browser after reset | Still logged in | Logged 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.
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
- OWASP Forgot Password Cheat Sheet
- OWASP Authentication Cheat Sheet
- OWASP Top 10:2025 A07 Authentication Failures
- 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 repoLeakcade 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