Building a Secure Password Reset Flow

A
Author
26 Jun, 2026 1 min read 184 views

"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-referrer on 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.

Leave a comment

Comments are reviewed before they appear.