A well-built password reset email does two things right simultaneously: it closes a security-critical flow and it moves a frustrated user back into your product in under a minute. Most implementations fail on at least one of those counts. The token logic is insecure, or the email is confusing, or it lands in spam precisely when the user needs it most.
This post covers both sides for the two people who own the problem: the engineer building the flow and the product or growth person writing and owning the message.
The Token Contract: What Secure Reset Flows Actually Look Like
The security properties of your reset flow are determined entirely by how you generate, store, validate, and expire the token. Getting any one of these wrong creates a genuine attack surface.
The OWASP Forgot Password Cheat Sheet is the authoritative reference. Here is what it requires:
Token generation. Tokens must be “generated using a cryptographically secure random number generator.” This rules out predictable sequences, timestamp-based values, or anything derived from user data. Use crypto.randomBytes() in Node.js, secrets.token_urlsafe() in Python, or the equivalent in your stack.
Token storage. Do not store the raw token in your database. According to the OWASP Web Security Testing Guide, “a more secure implementation stores a hashed version of the token and compares the hash during validation.” A database breach that exposes raw tokens lets an attacker reset any account. Store sha256(token) alongside the user ID and expiry.
Token expiry. The same guide states that reset links “should rarely be more than an hour” in validity, noting “exactly how long is appropriate will depend on the site.” Most SaaS products land somewhere between 15 minutes (high-security, banking-adjacent) and 60 minutes (standard productivity apps). The table below compares the tradeoffs.
Single use. Tokens “should expire after they are used, otherwise they provide a persistent backdoor for the account.” Invalidate immediately on the first valid redemption, not after the user completes the password change form.
Account enumeration. The cheat sheet requires you to “return a consistent message for both existent and non-existent accounts” and “ensure that responses return in a consistent amount of time to prevent an attacker enumerating which accounts exist.” Show the same “if an account exists, you’ll receive an email” response regardless of whether you found a match in your database.
Rate limiting. Implement “protections against excessive automated submissions such as rate-limiting on a per-account basis.” Without this, an attacker can flood a target’s inbox or exhaust your sending quota.
The minimal token contract in code terms
1. Receive email address from user
2. Look up account (do not reveal result to user)
3. Generate token = CSPRNG(32 bytes) → url-safe base64
4. Store: sha256(token), user_id, expires_at = now + 15-60 min
5. Build link: https://app.example.com/reset?token={raw_token}
6. Send email with that link
7. On link visit: look up sha256(incoming_token), check expiry
8. If valid: authenticate user, invalidate token immediately
9. Let user set new password
The raw token travels only in the email link and in transit (HTTPS). It never persists in your database.
Token expiry: security vs. UX tradeoff
| Expiry window | Security posture | UX tradeoff | Common use case |
|---|---|---|---|
| 10-15 minutes | Strongest | User must act immediately; mobile users may time out | Finance, healthcare-adjacent apps |
| 30 minutes | Strong | Comfortable for most users | Standard B2B SaaS |
| 60 minutes | Moderate | Maximum OWASP recommends | Consumer apps with less sensitive data |
| 24+ hours | Weak | Highest convenience | Not recommended for accounts with sensitive data |
What the Reset Email Must Contain
The email itself is a transactional email triggered by a user action, which means its only job is to get the user to their destination without friction. Postmark’s guide puts it plainly: “never send users a password in plain text because it’s a huge security vulnerability.” Beyond that, here are the required elements:
One action, one button. The reset link should be an obvious button or bolded anchor. Because “the URL will inevitably be clunky with the expiring token, it’s best if the link is the HREF attribute” (Postmark) rather than displayed as raw text. Include the raw URL below the button as a plain-text fallback for email clients that strip HTML.
A clear expiry statement. Tell the user exactly when the link expires: “This link is valid for 30 minutes.” Vague copy like “this link expires soon” creates support tickets.
An “I didn’t request this” line. A short sentence: “If you didn’t request a password reset, you can ignore this email. Your account is not affected.” This is reassuring copy that also allows the user to self-invalidate by contacting support if something is genuinely wrong.
Sender identity that matches your product. From name should be your app or brand name, not a generic “noreply@” address. Knock’s deliverability guide identifies no-reply addresses as a signal “that can hurt inbox placement because they signal illegitimacy to ISPs.”
No marketing content. Including promotional material in a reset email can cause inbox providers to reclassify it as marketing, increasing the probability it lands in spam. This is a standard deliverability concern across providers and applies to reset emails in particular because they are triggered by direct user action.
What to include vs. what to leave out
| Include | Leave out |
|---|---|
| Single reset button (HTML) + plain-text URL | Product news or feature announcements |
| Expiry time stated clearly | Secondary CTAs (“upgrade while you’re here”) |
| “Didn’t request this? Ignore it” line | Personalization tokens that could fail (render as raw variables) |
| Support contact or link | Multiple competing action links |
| From name matching your product | HTML-heavy decorative design (increases spam score risk) |
Subject Lines That Get Opened Fast
Password reset emails have some of the highest open rates of any email type precisely because the user just asked for them. The goal of the subject line is not creativity — it is clarity and speed. The user is locked out. They want confirmation the email arrived.
Patterns that work:
- [Product]: Reset your password — unambiguous, brand-first
- Your [Product] password reset link — descriptive, action-ready
- Reset your password (link expires in 30 minutes) — urgency without alarm; the expiry is useful, not manipulative
- Action required: [Product] password reset — “Action required” works for users who scan headers quickly
Patterns to avoid:
- Anything resembling a phishing subject (“Your account has been compromised”, “Security alert: unauthorized access”)
- Questions (“Did you forget your password?”) — adds an extra cognitive step
- Over-personalized subjects that look automated (“Hi {first_name}, your reset link is ready”)
An Annotated Reset Email Template
Below is a minimal reset email. Copy it, adapt the brand voice, and fill in the expiry duration that matches your token contract.
Subject: Reset your [Product] password
Hi [first_name or “there”],
We received a request to reset the password for your [Product] account.
This link expires in 30 minutes. If the button above doesn’t work, copy and paste this URL into your browser:
https://app.example.com/reset?token=TOKEN
If you didn’t request this, you can ignore this email. Your password will not change.
[Product] Support [email protected]
A few notes on this template:
- No greeting with a full name by default. If your user database has reliable first names, use them. If names are inconsistent (blank fields, “test”, all-caps), “Hi there” is safer than broken personalization.
- The token URL appears twice — once as the button HREF and once as a plain-text URL below. This covers plain-text email clients and clipboard-paste scenarios.
- No images or heavy branding. A one-logo header is fine. Full HTML templates with images increase the chance of spam filtering on a message that needs to land.
Deliverability: Why Reset Emails Fail to Arrive
A password reset email that arrives 90 seconds late — or lands in spam — is a support ticket and a potential churn event. The user is already frustrated. You need this specific message in their inbox within seconds.
The most common infrastructure failure is sending reset emails through the same stream as marketing email. Marketing email accumulates spam complaints over time. If your reset emails share that sending reputation, they inherit its problems. Keep password resets on a dedicated transactional email channel with its own subdomain (e.g., auth.yourdomain.com), separate from your marketing sends.
Beyond infrastructure, Knock’s deliverability guide identifies the following as the most common causes of reset email failure: missing or misconfigured SPF/DKIM/DMARC authentication, no-reply sender addresses, and domain reputation damage from unrelated bulk sending. If your emails are going to spam, audit those three areas first before investigating the reset flow specifically.
Postmark’s guidance on delivery speed is a practical benchmark: “if it takes an email more than 20 seconds to arrive in an inbox, it’s slow. If it takes more than a minute, your customers might move on.” At that point you face support tickets, duplicate reset requests that add noise to your logs, and users who lock themselves out further by clicking an already-invalidated link.
Building the HTML and plain-text versions correctly also matters here. Sending HTML email without a plain-text fallback is a signal that spam filters use — reset emails should always include both versions.
If you need Coldletter to handle transactional sends — routing reset emails on a separate authenticated stream from your lifecycle and marketing — coldletter.com is built for that use case.
Frequently Asked Questions
How long should a password reset link be valid?
The OWASP Web Security Testing Guide states that reset links “should rarely be more than an hour.” In practice, 15-30 minutes is standard for B2B SaaS products because it balances security (shorter windows limit brute-force opportunity) with usability (users on mobile or switching devices still have enough time to complete the reset).
Why didn’t I receive the password reset email?
The most common causes are: (1) the email landed in the spam or junk folder due to authentication issues (SPF/DKIM/DMARC not configured on the sending domain); (2) the address used does not match any account (most apps send the same response for both cases to prevent account enumeration, so there is no error message either way); or (3) the sending domain shares infrastructure with marketing email that has accumulated spam complaints. Checking the spam folder first, then verifying your email address, covers most cases.
Should I store the raw reset token or a hash of it?
Store only the hash. According to the OWASP Web Security Testing Guide, storing tokens in plaintext means “an attacker who gains database access may be able to reuse them to reset user passwords.” The standard approach is to hash the token with SHA-256, store the hash alongside the user ID and expiry timestamp, and compare the hash of the incoming token on validation. The raw token exists only in the link sent to the user’s email.
Why do password reset emails go to spam?
The most common reasons are shared sending infrastructure with marketing email (which accumulates spam complaints), missing SPF/DKIM/DMARC authentication on the sending domain, using a no-reply sender address that inbox providers flag as low-trust, and including marketing content in the reset email itself. Separating reset emails onto a dedicated sending subdomain and ensuring proper authentication resolves the majority of spam placement issues.
Should the password reset flow tell users if their email address is registered?
No. OWASP’s Forgot Password Cheat Sheet requires apps to “return a consistent message for both existent and non-existent accounts.” Revealing whether an email is registered allows attackers to enumerate valid accounts — a reconnaissance step before targeted phishing or credential-stuffing attacks. Show the same “if an account exists, you’ll receive an email” response regardless of whether the address matched.
Can I reuse a password reset token if the user requests a second reset?
No. Tokens must be single-use and each new reset request should generate a new token (invalidating any previously issued, unexpired token for that account). Allowing reuse means an intercepted link remains valid even after a successful reset, leaving the account exposed. Invalidate the previous token on any new reset request, not just on successful redemption.
I’ve spent my career building software at scale with a soft spot for email: deliverability, lifecycle campaigns, and getting messages to actually land. I started Coldletter to fix what bugged me about transactional and marketing email tools. I’m based in Vancouver.
