Building a Secure Password Reset Flow
"Forgot password" is a back door into every account, so it deserves as much care as the login form.
1. Requesting a reset
import { randomBytes, createHash } from 'node:crypto';
const sha256 = (s) => createHash('sha256').update(s).digest('hex');
app.post('/auth/forgot', rateLimit({ windowMs: 15 * 60_000, limit: 5 }), async (req, res) => {
const user = await db.users.findByEmail(req.body.email);
if (user) {
const token = randomBytes(32).toString('base64url'); // unguessable
await db.passwordResets.create({
userId: user.id,
tokenHash: sha256(token), // store only the hash
expiresAt: new Date(Date.now() + 30 * 60_000), // 30 minutes
});
await mailer.send(user.email, 'Reset your password', `https://example.com/reset?token=${token}`);
}
// Same response either way: don't reveal whether the email has an account
res.json({ message: 'If an account exists for that email, we have sent a reset link.' });
});
2. Using the link
app.post('/auth/reset', async (req, res) => {
const record = await db.passwordResets.findValid(sha256(req.body.token)); // unused and not expired
if (!record) return res.status(400).json({ error: 'This link is invalid or has expired.' });
await db.transaction(async (tx) => {
await tx.users.setPassword(record.userId, await argon2.hash(req.body.password));
await tx.passwordResets.markUsed(record.id);
await tx.passwordResets.invalidateOthers(record.userId);
await tx.sessions.revokeAllForUser(record.userId); // sign out everywhere
});
await mailer.send(/* "Your password was changed" notification */);
res.json({ ok: true });
});
Checklist
- Tokens are random (at least 128 bits), single-use and short-lived.
- Only hashes are stored, so a database leak can't be used to reset passwords.
- The response doesn't reveal whether an account exists, and timing is similar for both cases (send email in a background job).
- Rate limits per IP and per email address.
- Build the reset URL from your configured site URL, never from the request's Host header.
- Add
Referrer-Policy: no-referreron the reset page so the token doesn't leak to third-party resources. - If 2FA is enabled, still require the second factor after the reset.
- Notify the user by email when the password changes.
Was this article helpful?
Comments
No comments yet. Have a question or a tip? Be the first to share it.