Password reset tokens that don't expire: how to build a safe reset flow
By Obscura Security · Updated · Next.js, Node.js, Express, Cursor, Claude, Lovable, Bolt
Short answer
A safe password reset uses a random token from crypto.randomBytes(32), stores only its hash, expires in 30 to 60 minutes, works once, builds the link from a fixed base URL (never the request's Host header), and gives the same response whether or not the email exists. Or use your auth provider's built-in reset flow.
A password reset is a second way into every account. If it’s weaker than the login, the login’s strength doesn’t matter. Hosted auth services handle this for you. A reset flow an AI assistant wrote from scratch for your app often has several of the problems below.
Why this happens
When you ask an AI tool for “forgot password”, it produces something that works: a form, a token saved in the database, an email with a link, and a page that sets the new password. The happy path passes. The checks nobody tried are the ones missing:
- Guessable tokens. Tokens made with
Math.random(), the current time, a counter, or a hash of the user’s email can be predicted or brute forced. - Tokens that never expire. An old reset email sitting in an inbox, a forwarded message, or a leaked log keeps working for months.
- Tokens that can be used again. After a reset the same link still works, so anyone who sees it later can take the account over.
- Tokens stored as plain text. A database leak becomes an account takeover for anyone with a reset pending.
- Links built from the Host header. The email contains whatever domain the request claimed to come from.
- “Email not found” errors. The form shows which addresses have accounts. Attackers use that to build target lists.
- No limit on requests. The form can be used to flood someone’s inbox or to guess short codes. See no rate limit on login or AI endpoints.
How to check your own app
Use test accounts you own.
- Compare tokens. Request two resets for the same account and look at the two links. If they look similar, are short, or contain a number that grows, they’re not random enough.
- Reuse a link. Reset the password with a link, then open the same link again. If it still lets you set a password, tokens are not single use.
- Check expiry. Find the token in the database and look for an expiry column. Edit its date to the past, or wait out the lifetime. The link should be refused. If there’s no expiry column, there is no expiry.
- Look at the database. If the token column holds the exact string from the email, tokens are stored in plain text.
- Try the Host header. Send a reset request for your own account with a changed host header, then read the email:
curl -X POST "https://yourapp.com/api/forgot-password" \
-H "Content-Type: application/json" \
-H "X-Forwarded-Host: evil.example" \
-d '{"email": "[email protected]"}'
If the link in the email points at evil.example, your code builds links from the request. Some hosts overwrite or ignore this header, so also read the code: look for req.headers.host, x-forwarded-host, or req.nextUrl.origin being used to build the URL.
- Try a made-up address. Submit one that has no account. If the response differs in text, status code or speed, your form leaks which emails exist.
The fix
Option 1: use your provider’s reset flow
If you use Supabase Auth or Firebase Auth, don’t write your own. In Supabase, supabase.auth.resetPasswordForEmail(email, { redirectTo }) sends a link, and the redirect has to be on your allowed redirect URLs list under the auth URL configuration. In Firebase, use sendPasswordResetEmail(auth, email). Both handle token generation, expiry and single use for you. In Supabase, check the email link expiry setting and keep it to an hour or less.
Option 2: build it properly
Here’s the shape of a safe flow in a Node.js app. It uses Prisma-style calls, which you can adapt to your own database code.
Request step
import { randomBytes, createHash } from 'node:crypto'
const sha256 = (value: string) => createHash('sha256').update(value).digest('hex')
export async function requestReset(email: string) {
const user = await db.user.findUnique({ where: { email: email.toLowerCase() } })
if (user) {
const token = randomBytes(32).toString('hex')
// Remove older unused tokens so only the newest link works.
await db.passwordResetToken.deleteMany({ where: { userId: user.id } })
await db.passwordResetToken.create({
data: {
userId: user.id,
tokenHash: sha256(token),
expiresAt: new Date(Date.now() + 30 * 60 * 1000), // 30 minutes
},
})
// Fixed, configured base URL. Never the request's Host header.
const link = `${process.env.APP_BASE_URL}/reset-password?token=${token}`
await sendEmail(user.email, link)
}
// Same response whether or not the account exists.
return { message: 'If that address has an account, we have sent a link.' }
}
APP_BASE_URL is an environment variable you set once, such as https://yourapp.com. If sending the email is slow, queue it or run it without waiting for it, so a real account doesn’t take noticeably longer to answer than a fake one.
Reset step
export async function resetPassword(token: string, newPassword: string) {
const tokenHash = sha256(token)
return db.$transaction(async (tx) => {
const row = await tx.passwordResetToken.findUnique({ where: { tokenHash } })
if (!row || row.usedAt || row.expiresAt < new Date()) {
throw new Error('Invalid or expired link')
}
// Only one request can flip usedAt from null, even if two arrive together.
const claimed = await tx.passwordResetToken.updateMany({
where: { tokenHash, usedAt: null },
data: { usedAt: new Date() },
})
if (claimed.count !== 1) throw new Error('Invalid or expired link')
await tx.user.update({
where: { id: row.userId },
data: { passwordHash: await hashPassword(newPassword) },
})
// Sign the account out everywhere, because someone else may have had access.
await tx.session.deleteMany({ where: { userId: row.userId } })
})
}
hashPassword is whatever your login already uses, such as argon2 or bcrypt. The point of the updateMany with usedAt: null is that two simultaneous requests can’t both succeed. Give the token column a unique index on tokenHash.
Smaller things that matter
- Send the page that reads the token with a
Referrer-Policy: no-referrerheader so the token isn’t passed on in a Referer header to other sites. - Rate limit the request step and the reset step per IP and per account.
- Email the account owner after a successful reset, so a surprise is noticed.
- Don’t log the token or the full reset URL.
Mistakes to avoid
- Short numeric codes with no attempt limit. Six digits is a million guesses, and a script is fast. Use long links, or lock out after a few wrong tries.
- Using the same message pattern in signup and login. “Email already registered” leaks the same thing the reset form does.
- Verifying nothing about the email. If accounts can be created with addresses nobody confirmed, see signup without email verification.
- Returning the token in the API response so the frontend can “send” it. Only the email should carry it.
Common questions
- How long should a password reset link last?
- Between 30 and 60 minutes is typical. The link should also stop working after one use, and when a new reset is requested, any older link for that account should stop working.
- Is a UUID a good reset token?
- A version 4 UUID from crypto.randomUUID() is random and long enough, though randomBytes(32) gives more room and is the usual choice. Avoid anything built from a timestamp, a counter, the user's ID or email, or Math.random(). Version 1 UUIDs include a timestamp and are not meant to be secret.
- Why store a hash of the token and not the token?
- If someone gets a copy of your database, stored tokens would let them reset any account that has a pending request. A hash lets you check a token someone presents without keeping a usable one. SHA-256 is enough here because the token is already long and random.
- What is host header poisoning?
- If your code builds the reset link from the Host header of the incoming request, an attacker can send a reset request for a victim's email with the Host set to a domain they control. The victim gets a genuine email from you with a link to the attacker's site, and the token arrives there when they click.
- Should the page say 'no account with that email'?
- No. Say something like 'If that address has an account, we have sent a link', and return the same response and roughly the same timing either way. Otherwise your form tells strangers which email addresses have accounts.
Read next
Written by Obscura Security, an Australian practice that checks web apps for problems like this one. If you want a second pair of eyes on yours, email [email protected].