HomeProjectsBlogContact

How to Build a Secure Password Reset Flow

Password reset is one of the most attacked features in any app. How to generate and store reset tokens, avoid leaking which emails exist, expire and invalidate correctly, and protect users after a reset.

How to Build a Secure Password Reset Flow

"Forgot your password?" is a tiny link with a huge responsibility. A weak reset flow lets attackers take over accounts without ever knowing the password, which makes it one of the most targeted features in any web app. The good news: a secure flow follows a clear recipe.

The overall flow

  1. User enters their email on the "forgot password" page.
  2. If an account exists, the server creates a reset token and emails a link containing it.
  3. User clicks the link and chooses a new password.
  4. The server verifies the token, updates the password and invalidates the token.

Simple. The security lives in the details of each step.

Step 1: don't reveal whether an account exists

Always show the same message, whether or not the email is registered:

"If an account exists for that email, we've sent a password reset link."

Otherwise attackers can use your form to discover which emails have accounts (account enumeration), then target them. Also try to keep response times similar in both cases, for example by sending the email from a background job either way. (Background jobs.)

Step 2: generate a strong token

Use a cryptographically secure random generator, never Math.random(), timestamps or user IDs:

import crypto from 'node:crypto'

const token = crypto.randomBytes(32).toString('base64url') // send this in the email
const tokenHash = crypto.createHash('sha256').update(token).digest('hex') // store this

Step 3: store only a hash of the token

Treat reset tokens like passwords. If your database leaks, plain tokens would let attackers reset anyone's password. Store a hash (a fast hash like SHA-256 is fine here, since the token is long and random), along with:

  • the user ID
  • an expiry time, short: 15 to 60 minutes
  • a "used" flag or timestamp

When a user requests a new reset, invalidate any previous unused tokens for that account.

Step 4: the reset link

https://yourapp.com/reset-password?token=Gx9k...
  • Always use HTTPS.
  • On the reset page, add Referrer-Policy: no-referrer (or similar) so the token isn't leaked to third-party scripts or links via the Referer header.
  • Avoid loading third-party analytics or ads on the reset page.

Step 5: verify and update

When the user submits a new password:

  1. Hash the submitted token and look it up.
  2. Reject if not found, expired or already used, with a generic message and a link to request a new one.
  3. Validate the new password: a sensible minimum length (often 12 or more characters), and ideally a check against lists of known breached passwords.
  4. Hash the new password with a proper password hashing algorithm (Argon2id, bcrypt or scrypt). (Security checklist.)
  5. Mark the token as used, in the same transaction as the password update.

Step 6: protect the account afterwards

  • Invalidate existing sessions and refresh tokens. If an attacker was logged in, they're now kicked out. (Sessions vs JWTs.)
  • Send a confirmation email: "Your password was changed. If this wasn't you, contact us." That gives the real owner a chance to react.
  • Don't automatically log the user in if your app has MFA. Require the second factor as usual.

Rate limiting

Without limits, attackers can flood users with reset emails, use your app to spam, or brute-force tokens.

  • Limit reset requests per email address and per IP.
  • Limit token verification attempts.
  • Return the same generic responses when limits trigger.

(Rate limiting guide.)

Email security

Reset emails are only as safe as the email channel. Make sure they arrive reliably (and not in spam) with proper SPF, DKIM and DMARC. (Email deliverability.) Keep the email short, clear about what was requested, and explicit that the user can ignore it if they didn't ask for a reset.

Common mistakes

  • Tokens that never expire, or last for days
  • Tokens stored in plain text
  • Tokens that can be used more than once
  • Different messages for registered and unregistered emails
  • Not invalidating sessions after a reset
  • Security questions ("mother's maiden name") as a reset method; answers are often guessable or public
  • Sending the user's current password by email (which means you're storing passwords in a recoverable form)

Testing the flow

  • Request a reset for an existing and a non-existent email: identical responses?
  • Use a token twice: second use rejected?
  • Use an expired token: rejected?
  • Request two resets: does the first token stop working?
  • Are other sessions logged out after the reset?

FAQ

How long should a password reset link be valid?

Usually 15 to 60 minutes. Short lifetimes limit the window for misuse if the email is intercepted or the inbox is compromised.

Should I store password reset tokens in the database?

Store only a hash of the token, along with its expiry and usage status, never the raw token. Treat it with the same care as a password.

Should I tell users if an email isn't registered?

No. Show the same message for every request so attackers can't discover which email addresses have accounts.

Keep reading

More from the workbench

All articles →