Password Reset Email: Engineering and Copy Best Practices
A secure password reset email needs single-use tokens, a short expiry, and copy that does not leak account existence. Here are the engineering and UX patterns to ship.
By SESMetric Editorial
A password reset email looks trivial until you ship one that leaks which addresses have accounts, replays tokens after a successful change, or sits in spam while a user assumes the reset failed. This guide walks through the token, transport, copy, and template choices that make the flow safe and recoverable.
What a password reset email must do
A working reset flow has five non-negotiable properties. Miss any of them and the email becomes a foothold instead of a recovery tool.
- Prove that the requester controls the inbox on file.
- Carry a token that works exactly once.
- Expire on a short clock, measured in minutes, not days.
- Reveal nothing about whether the target address has an account.
- Land in the inbox quickly, with a plain-text part for clients that strip HTML.
The rest of this article unpacks each requirement and ends with the security pitfalls that turn a recovery flow into an account takeover.
Token design: single-use and tamper-proof
The link you embed in the email is the entire authentication surface. Treat it as a bearer credential and design it accordingly.
Random token, stored hashed
The most common pattern is also the simplest. Generate a high-entropy random string, hand the raw value to the user inside the email, and store only its hash in the database. When the user clicks the link, you hash the incoming value and look it up. The database never holds a credential that an attacker with read access could replay.
import secrets, hashlib, datetime as dt
def issue_reset_token(user_id: int, db) -> str:
raw = secrets.token_urlsafe(32) # 256 bits of entropy
digest = hashlib.sha256(raw.encode()).hexdigest()
db.execute(
"INSERT INTO password_resets "
"(user_id, token_hash, expires_at, used_at) "
"VALUES (?, ?, ?, NULL)",
(user_id, digest, dt.datetime.utcnow() + dt.timedelta(minutes=20)),
)
return raw # only this value goes in the email
HMAC-signed token (stateless)
If you do not want a database row per reset attempt, sign a small payload with a server secret. The token verifies without a lookup, and you can stuff the user id and issued-at timestamp inside.
import hmac, hashlib, base64, json, time
SECRET = b"server-side-secret-rotated-quarterly"
def issue_signed_token(user_id: int) -> str:
payload = {"sub": user_id, "iat": int(time.time()), "nonce": secrets.token_hex(8)}
body = base64.urlsafe_b64encode(json.dumps(payload).encode()).rstrip(b"=")
sig = hmac.new(SECRET, body, hashlib.sha256).digest()
return body.decode() + "." + base64.urlsafe_b64encode(sig).rstrip(b"=").decode()
The trade-off is real. Stateless tokens are cheap to verify, but revoking one before its iat expires requires a deny-list, which puts you most of the way back to the database approach. Pick stateful storage unless you have a strong reason not to.
Expiry window: 15 to 30 minutes
Shorter windows reduce the phishing replay surface; longer windows raise the chance the user actually finishes the flow on a phone with two-factor friction. Fifteen to thirty minutes is the sweet spot for consumer accounts. Internal admin tools should sit at the low end; rarely used B2B portals can stretch to an hour.
Enforce expiry at lookup time:
row = db.fetchone(
"SELECT user_id, expires_at, used_at FROM password_resets "
"WHERE token_hash = ?",
(hashlib.sha256(submitted.encode()).hexdigest(),),
)
if row is None or row.used_at is not None or row.expires_at < dt.datetime.utcnow():
return generic_failure_response()
Never extend an existing token. If the user requests another reset, issue a new row and let the old one age out, or invalidate it immediately.
Invalidate after use and after password change
A token must die the moment it does its job. Flip used_at inside the same transaction that updates the password hash, never before, never after.
with db.transaction():
db.execute("UPDATE users SET password_hash = ? WHERE id = ?", (new_hash, user_id))
db.execute("UPDATE password_resets SET used_at = ? WHERE id = ?", (dt.datetime.utcnow(), reset_id))
db.execute(
"UPDATE password_resets SET used_at = ? "
"WHERE user_id = ? AND used_at IS NULL",
(dt.datetime.utcnow(), user_id),
)
The third statement is the one teams forget. When the password actually changes, kill every outstanding reset token for that account. Otherwise an attacker who triggered a reset earlier in the day still holds a working link.
Do not reveal whether an account exists
The single most common mistake in this flow is account enumeration. The submission form responds with "no account found" for unknown addresses, or sends an email only when an account exists, or returns in 40 ms for unknowns and 300 ms for hits. Each of these turns the endpoint into a free user-directory scanner.
Return the same HTTP status, the same body, the same response timing, and the same user-facing message in both cases:
If an account with that email exists, you will get a link shortly. Check your inbox in the next few minutes.
You have two acceptable email-side choices for the unknown-address case:
- Send nothing. The generic UI message is enough; attackers cannot distinguish silence.
- Send a short note saying "someone tried to reset a password for this address, but no account exists. If this was you, sign up here." This is friendlier for users who forgot which email they registered with.
Pick one and apply it consistently. Do not mix them, because differential delivery timing is itself an oracle.
Rate-limit the send-reset endpoint
Even with enumeration-safe copy, an unlimited send endpoint is an inbox flood vector. Cap per-email and per-IP independently.
# Redis-backed sliding window
def allow_reset(email: str, ip: str, r) -> bool:
now = int(time.time())
for key, limit, window in [
(f"reset:e:{email}", 3, 900), # 3 per 15 min per email
(f"reset:e:{email}:d", 10, 86400), # 10 per day per email
(f"reset:i:{ip}", 20, 900), # 20 per 15 min per IP
]:
count = r.incr(key)
if count == 1:
r.expire(key, window)
if count > limit:
return False
return True
When a limit trips, still return the generic success response. Do not surface the rate limit to the caller, or you have rebuilt the enumeration oracle from a different angle.
Subject line and preview text
The subject is the first deliverability signal and the first user-facing line. Keep it specific, brand-prefixed, and free of urgency words that trigger spam filters.
- Good:
Reset your Acme password - Good:
Acme password reset link (expires in 20 minutes) - Bad:
URGENT: Action required on your account - Bad:
Password reset!!!
Preview text, the snippet most clients render after the subject, should restate the action and the expiry in under ninety characters: Click the link to set a new password. The link expires in 20 minutes. Set it via a hidden preheader span at the top of the HTML body.
Accessibility and plain-text fallback
Always send a multipart/alternative message with both an HTML and a plain-text part. Some clients strip HTML, some users run screen readers that prefer plain text, and the text part doubles as a spam-score tiebreaker.
Inside the HTML, render the full reset URL as visible text in addition to wrapping it on a button. Users on locked-down corporate clients often see button links rewritten or stripped, and a visible URL lets them copy-paste. Use a real <a> element with an underline and a contrast ratio above 4.5 against the background.
A working template
The snippets below are minimal Jinja2. They render a message that survives Gmail clipping, Outlook rendering, and a screen reader.
<!doctype html>
<html lang="en">
<body style="font-family: -apple-system, Helvetica, Arial, sans-serif; color: #111; background: #fff; margin: 0; padding: 24px;">
<span style="display:none; visibility:hidden; opacity:0; height:0; width:0; overflow:hidden;">
Click the link to set a new password. The link expires in 20 minutes.
</span>
<h1 style="font-size: 20px; margin: 0 0 16px;">Reset your {{ brand }} password</h1>
<p>You asked to reset the password for the {{ brand }} account on {{ email }}.</p>
<p>
<a href="{{ reset_url }}"
style="display:inline-block; padding: 10px 16px; background:#111; color:#fff; text-decoration:none; border-radius: 6px;">
Set a new password
</a>
</p>
<p>Or copy this link into your browser:</p>
<p style="word-break: break-all;">
<a href="{{ reset_url }}" style="color:#0a58ca;">{{ reset_url }}</a>
</p>
<p style="color:#555; font-size: 13px;">
This link expires in 20 minutes and can be used once. If you did not request a reset, ignore this email.
</p>
</body>
</html>
Reset your {{ brand }} password
You asked to reset the password for the {{ brand }} account on {{ email }}.
Set a new password:
{{ reset_url }}
This link expires in 20 minutes and can be used once.
If you did not request a reset, ignore this email.
-- {{ brand }}
Send both parts in the same message. Configure Reply-To to a monitored address, and authenticate the sending domain with SPF, DKIM, and a DMARC policy at quarantine or reject.
Security pitfalls to avoid
The mistakes below show up across production password reset email implementations and each has shipped at least one disclosed breach.
- Leaking account existence through response body, HTTP status, or response timing.
- Logging the raw token in application logs, request traces, or analytics events.
- Keeping the token valid after a successful password change.
- Sending the link over HTTP, or accepting the token submission over HTTP.
- Putting the token in the URL query when the next page makes an outbound third-party request, which leaks the token via
Referer. Prefer a one-time exchange that swaps the URL token for a short-lived session cookie before any third-party asset loads. - Expiry windows longer than an hour for consumer accounts.
- No rate limit on the send endpoint, or a rate limit that returns a different status when tripped.
- Missing SPF, DKIM, or DMARC, which lets attackers spoof your own reset emails.
- Including the username and the new-password form on the same page reachable from the email, with no re-entry of the old password and no rate limit on the form itself.
Treat the reset path as a small, isolated auth surface, audit it on every release, and the flow becomes the boring, reliable recovery tool it should be.