# 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.

Published 2026-10-26. Source: https://mailions.com/blog/password-reset-email-security

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](/blog/send-otp-email), 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 `Referer` header. Set `Referrer-Policy: no-referrer` on 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](/blog/what-is-dmarc) 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](/blog/what-is-transactional-email), and [get in touch](/contact) 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.
