Password reset emails: the security mistakes to avoid
How to build a password reset flow that can't be abused, from token generation and expiry to account enumeration and what the email should say.
A password reset email is a key to the account, delivered by one of the least secure channels there is. If the flow around it is sloppy, an attacker doesn't need to guess a password. They only need to talk your reset form into handing over a way in.
Most of the failures below are small. They also come up again and again.
Generate an unguessable token
The reset link carries a token. Make it from a cryptographically secure random source with at least 128 bits, which is 32 hex characters. In Node, crypto.randomBytes(32).toString('hex'). In Python, secrets.token_urlsafe(32). In PHP, bin2hex(random_bytes(32)).
Don't build tokens from a timestamp, a user ID, an email address or a counter, even if you hash them. Anything predictable can be reproduced.
Store a hash, not the token
Treat the token like a password. Save a hash of it (SHA-256 is fine for a long random token) along with the user, the creation time and a used flag. When the link is clicked, hash the incoming value and compare. If your database ever leaks, the attacker gets hashes they can't turn back into working links.
Make it short-lived and single-use
Thirty minutes to an hour is typical. Reject expired tokens, and mark a token used the moment it succeeds. When someone requests a new reset, invalidate older ones, so only the latest link works. And after the password changes, invalidate every outstanding token and sign out existing sessions.
Say the same thing whether the account exists or not
If the form says "No account with that email", you've given anyone a way to check who uses your product. Always reply: "If that address has an account, we've sent a reset link." The page should look and behave the same in both cases, including timing when you can manage it.
Rate limit the request
Without limits, the reset form becomes a free email cannon. Someone can pour a thousand reset emails into a victim's inbox using your domain, and the complaints hurt your sender reputation. Limit by account and by IP, for example a few per hour. This is the same advice as for one-time codes, and it matters for the same reasons.
Keep the token out of the wrong places
The link will be seen by more systems than you expect.
- Logs. Don't write full URLs with tokens into application logs or analytics.
- Referrers. If the reset page loads third-party scripts or images, the browser may leak the URL in the
Refererheader. SetReferrer-Policy: no-referreron that page, and keep it free of external resources. - Third-party click tracking. Skip link rewriting in security emails. It sends the token through someone else's server.
- Query strings in shared screens. Consider exchanging the token for a session cookie on first load and redirecting to a clean URL.
Don't queue secrets carelessly
When the mail server is down, a retry queue is a good idea for most email, because a receipt that arrives late is still useful. A reset link is different. It carries a secret, and storing it in a queue puts the secret in one more place. A link that arrives hours late is also probably expired. For Mailions's own panel we deliberately never queue password-reset links for later retry. If sending fails, we show the person an honest error and let them ask again. Consider the same rule in your own system.
Write an email that doesn't help phishers
The legitimate message should be easy to tell from a fake one.
- Plain subject: "Reset your password"
- Short body, one button, and the raw link as text under it
- The expiry time
- A line that says what to do if they didn't ask ("Ignore this email. Your password won't change.")
- No attachments, no promotions
- Sent from a domain with SPF, DKIM and DMARC in place, so a spoofed copy fails checks. What DMARC is explains the last part.
Never put the new password in an email. Never.
After the change
Send a second email saying the password was changed, with a way to report it if the person didn't do it. That's how people discover an account takeover quickly. If the account has two-factor sign-in, a password reset should not turn it off. Otherwise the reset link becomes a way around your strongest protection.
Checklist
- 128-bit random token, hashed at rest
- Expires within an hour, works once
- Old tokens die on new requests and on password change
- Same response for known and unknown addresses
- Rate limits per account and per IP
- Token kept out of logs, referrers and click trackers
- Not queued for later retry
- Two-factor sign-in untouched
- Confirmation email after the change
Sending it
Reset emails are transactional mail, so they need an authenticated domain and a quick, reliable path. See what is transactional email, and get in touch if you'd like to send them from your own domain.
Frequently asked questions
How long should a password reset link be valid?
Short: typically 15 to 60 minutes, and single-use. Invalidate it as soon as it is used, when a newer link is requested, and after the password has been changed.
Should a password reset form say whether an email address is registered?
No. Show the same message either way, such as "If an account exists, we have sent a link". Otherwise anyone can use the form to find out who has an account.
Should password reset tokens be stored in the database?
Store only a hash of the token, never the token itself, so a leaked database or backup cannot be used to reset accounts. Generate the token with a cryptographically secure random generator.