Password reset email templates: examples and HTML code
Password reset email templates with tested HTML code, secure token handling, and React Email examples for production auth flows. Includes database schema for single-use tokens, MIME multipart setup, and send.io dispatch calls so you can ship a secure reset flow without trial and error.
On this page
TL;DR: A production reset email needs a hashed, single-use token with a 15–30 minute expiry, an HTML template with a plain-text fallback, and a delivery API that returns idempotent results and fires delivery event webhooks. You can write the template as raw table-based HTML or as a React Email component that compiles to table-based HTML, then preview it across 100+ email clients and devices in Litmus or Email on Acid. The security checklist covers 2FA handling, account enumeration defense, and rate limits. send.io dispatches the reset email and handles inbound replies under one API.
A password reset email that renders beautifully but accepts a replayed token is a security incident with good typography. Reset emails are high-value targets because they contain time-sensitive credentials that grant account access. This article gives you a copy-paste HTML template, the database schema for token storage, and the send.io dispatch call to ship a secure reset flow without maintaining two providers.
Standard HTML password reset email template
This is the baseline template for any password reset email template implementation. It uses table-based layout because Outlook still renders HTML through the Word engine, and it includes a plain-text fallback via multipart/alternative so clients that strip HTML still deliver the reset link. Litmus and Email on Acid both provide real-time rendering feedback across 100+ email clients and devices, so run this template through either tool to verify rendering before you ship.
Reusable HTML reset snippet
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Reset your password</title>
<style>
@media (prefers-color-scheme: dark) {
.bg-dark { background-color: #1a1a1a !important; }
.text-dark { color: #e0e0e0 !important; }
}
</style>
</head>
<body style="margin: 0; padding: 0; background-color: #f4f4f4;">
<table role="presentation" width="100%" border="0" cellspacing="0" cellpadding="0" class="bg-dark">
<tr>
<td align="center" style="padding: 20px 0;">
<table role="presentation" width="600" border="0" cellspacing="0" cellpadding="0" style="max-width: 600px; background-color: #ffffff; border-radius: 8px; overflow: hidden;">
<tr>
<td style="padding: 40px 30px;">
<h1 style="margin: 0 0 20px; font-family: Arial, sans-serif; font-size: 24px; color: #111111;">Reset your password</h1>
<p style="margin: 0 0 20px; font-family: Arial, sans-serif; font-size: 16px; line-height: 24px; color: #333333;" class="text-dark">
You requested a password reset. Click the button below to choose a new password. This link expires in {{EXPIRY_MINUTES}} minutes.
</p>
<table role="presentation" border="0" cellspacing="0" cellpadding="0">
<tr>
<td align="center" bgcolor="#0095ff" style="border-radius: 5px;">
<a href="{{RESET_URL}}" target="_blank" rel="noopener noreferrer" style="display: inline-block; padding: 14px 28px; font-family: Arial, sans-serif; font-size: 16px; color: #ffffff; text-decoration: none; border-radius: 5px; font-weight: bold;">Reset Password</a>
</td>
</tr>
</table>
<p style="margin: 30px 0 0; font-family: Arial, sans-serif; font-size: 14px; line-height: 20px; color: #666666;" class="text-dark">
If you didn't request this, you can safely ignore this email. Your password won't change.
</p>
<p style="margin: 20px 0 0; font-family: Arial, sans-serif; font-size: 12px; line-height: 18px; color: #999999;" class="text-dark">
If you're having trouble with the button above, copy and paste this URL into your browser:<br>
<a href="{{RESET_URL}}" style="color: #0095ff; word-break: break-all;">{{RESET_URL}}</a>
</p>
</td>
</tr>
</table>
</td>
</tr>
</table>
</body>
</html>
Replace the {{RESET_URL}} placeholder server-side with the full HTTPS link containing the raw token. The inline styles are required because Gmail strips <style> tags in some contexts, and the 600px max width fits the Outlook preview pane without horizontal scrolling.
Common HTML email support issues
Different email clients render HTML through different engines, so a reset email that looks fine in Apple Mail may break in Outlook or invert colors in Gmail dark mode. The table below maps the rendering issue to the specific HTML fix.
Client | Issue | Fix |
|---|---|---|
Outlook (Windows desktop) | Classic Outlook uses the Word rendering engine and can show white lines at certain scales. New Outlook for Windows uses WebView2 (Edge), a modern web-based rendering engine, so the Word-specific quirks don't apply there | Use table-based layouts with |
Gmail (Web) | Auto-color inversion in dark mode. CSS sanitization. Clipping over 102KB | Use inline styles. Keep file size under 100KB. Gmail clips content over 102KB, a limit Google doesn't document officially but ESP help docs such as Mailmodo's describe |
Gmail (Android) | Partial color inversion (Gmail iOS applies full inversion). CSS sanitization. Clipping over 102KB | Declare |
Apple Mail (iOS/macOS) | Auto-inverts pure white and pure black backgrounds in dark mode | Use near-white and near-black hex values instead of pure white and black, such as |
Dark mode (varies by client) | Clients take different approaches: full color inversion, partial inversion, or no change at all | Set explicit colors with |
Reset email markup for developers
The Multipurpose Internet Mail Extensions (MIME) type for a reset email must be multipart/alternative so clients that block HTML (or users who prefer plain text) still receive a working reset link. The plain-text version must contain the same {{RESET_URL}} and the same disclaimer.
Subject: Reset your password
You requested a password reset. Copy and paste this link into your browser to choose a new password:
{{RESET_URL}}
This link expires in {{EXPIRY_MINUTES}} minutes.
If you didn't request this, you can safely ignore this email. Your password won't change.
If you omit the plain-text part, some spam filters will flag the message as suspicious, and accessibility tools that strip HTML will deliver an empty email. Check how your email API accepts a plain-text part alongside the HTML part, and confirm the exact field names in its send request reference (for send.io, start with the send.io API docs), so you can send both parts in one request. Swap the {{EXPIRY_MINUTES}} placeholder server-side in both parts to match your configured expiry window, which the security checklist below sets at 15–30 minutes.
Implementing React Email for password resets
React Email is the component-based alternative for teams already using React in their stack. It compiles to table-based HTML and exports both HTML and plain text, so you get the same client compatibility as raw HTML without hand-coding <table> tags. Choose React Email when your team already maintains a component library and wants version-controlled email templates.
Password reset component code
React Email provides a Button component that renders as a styled <a> tag, and a render utility that compiles the component to an HTML string. The component below includes the reset link, the expiry warning, and the security disclaimer.
import { Button, Html, Head, Body, Container, Text, Link } from "@react-email/components";
interface PasswordResetEmailProps {
resetUrl: string;
expiryMinutes: number;
}
export default function PasswordResetEmail({ resetUrl, expiryMinutes }: PasswordResetEmailProps) {
return (
<Html lang="en">
<Head />
<Body style={{ fontFamily: "Arial, sans-serif", backgroundColor: "#f4f4f4" }}>
<Container style={{ backgroundColor: "#ffffff", margin: "0 auto", padding: "40px 30px", maxWidth: "600px" }}>
<Text style={{ fontSize: "24px", fontWeight: "bold", color: "#111111", margin: "0 0 20px" }}>
Reset your password
</Text>
<Text style={{ fontSize: "16px", lineHeight: "24px", color: "#333333", margin: "0 0 20px" }}>
You requested a password reset. Click the button below to choose a new password. This link expires in {expiryMinutes} minutes.
</Text>
<Button
href={resetUrl}
style={{
backgroundColor: "#0095ff",
color: "#ffffff",
padding: "14px 28px",
borderRadius: "5px",
textDecoration: "none",
fontSize: "16px",
fontWeight: "bold",
display: "inline-block",
}}
>
Reset Password
</Button>
<Text style={{ fontSize: "14px", lineHeight: "20px", color: "#666666", margin: "30px 0 0" }}>
If you didn't request this, you can safely ignore this email. Your password won't change.
</Text>
<Text style={{ fontSize: "12px", lineHeight: "18px", color: "#999999", margin: "20px 0 0" }}>
If you're having trouble with the button above, copy and paste this URL into your browser:
</Text>
<Link href={resetUrl} style={{ color: "#0095ff", fontSize: "12px", wordBreak: "break-all" }}>
{resetUrl}
</Link>
</Container>
</Body>
</Html>
);
}
The React Email Button component renders as a table-based button for Outlook compatibility, and the render utility converts the component to an HTML string you can pass to your email API. The plain-text version requires a manual conversion step with the toPlainText utility (const text = toPlainText(html);), so you generate it from the rendered HTML instead of maintaining a separate .txt template. The component mirrors the raw HTML template above, so the greeting and expiry copy stay identical across both approaches. If you want personalization, add a placeholder like {{USER_NAME}} to the raw template and swap it server-side alongside {{RESET_URL}}.
HTML markup for email clients
React Email compiles to table-based HTML that passes the same client tests as the raw HTML snippet above. The compiled output includes inline styles, role="presentation" on layout tables, and a <style> block for dark mode media queries. You still need to test the compiled HTML in Litmus or Email on Acid because React Email cannot guarantee that Gmail's dark mode inversion won't break your color contrast.
Triggering secure automated password reset emails
The email is the delivery layer. The trigger logic is the security boundary. A reset flow that skips token hashing or single-use enforcement is vulnerable to replay attacks regardless of how clean the HTML looks.
Optimized password reset email workflow
- User requests reset: The client submits the account email to your
/forgot-passwordendpoint. - Server generates token: Create a token with at least 128 bits of entropy using a cryptographically secure random number generator.
- Server hashes token: Hash the token with Secure Hash Algorithm 256-bit (SHA-256) and store the hash in the database with an
expires_attimestamp. - Server sends email: Pass the raw token (not the hash) to the email template as the
{{RESET_URL}}query parameter. - User clicks link: The client submits the raw token to your
/reset-passwordendpoint. Protect the reset form against cross-site request forgery (CSRF) with a per-session CSRF token andSameSite=LaxorSameSite=Strictsession cookies, so a malicious page can't submit the form on the user's behalf. - Server validates token: Hash the submitted token and compare it to the stored hash. Check that
expires_atis in the future andused_atis null. - Server invalidates token: Mark the token as used by setting
used_atto the current timestamp. - Server invalidates sessions: Terminate every active session for the user and revoke any refresh tokens, so a hijacked session can't survive the reset. This step matters most when the reset was triggered by a suspected account compromise.
- Server sends confirmation email: Notify the account email address that the password changed, and include a way to lock or recover the account if the user didn't initiate the change.
Designing secure password reset tokens
The token must be at least 128 bits of entropy, which is 32 hex characters or 22 Base64URL characters. Generate it with a cryptographically secure pseudorandom number generator (CSPRNG), because at 128 bits of entropy an attacker can't practically guess a valid token by brute force. This sits above the 112-bit minimum that OWASP's Application Security Verification Standard (ASVS) sets for comparable random secrets (lookup secrets, V2.6.2), and the OWASP Forgot Password Cheat Sheet says reset tokens should be sufficiently long and random.
Store a hashed version of the token and compare the hash during validation. You can use SHA-256 for tokens because the token itself is already high-entropy, unlike passwords that require slower algorithms such as Argon2id or bcrypt.
The database schema must enforce single-use and expiry at the storage layer.
Field | Data type | Purpose | Notes |
|---|---|---|---|
| UUID or integer (choose based on your database and use case) | Primary key | Typically auto-generated |
| Same type as | Foreign key to users table | Indexed, required |
| CHAR(64) for a SHA-256 hash | Hashed reset token | SHA-256 hex digest (64 characters). Never store plaintext |
| TIMESTAMP | Token expiration time | UTC. Index for cleanup queries |
| TIMESTAMP | Token creation timestamp | UTC, for audit trail |
| TIMESTAMP | When token was consumed | NULL until used. Prevents reuse |
| VARCHAR(45) | Request IP address | Logged on every reset request, 45 characters fits IPv6 |
| TEXT | Client user agent string | Logged on every reset request for abuse audits |
Constructing secure password reset links
The reset link must be HTTPS only, and since the token travels inside the URL the user clicks (the {{RESET_URL}} in the templates above), the reset page has to protect it there. Set the Referrer-Policy: no-referrer header on the reset page so the token doesn't leak to third-party analytics or CDN logs, and keep full query strings out of the reset endpoint's access logs. When the user submits the new password, send the token in the POST request body rather than re-exposing it in the URL.
Password reset security checklist
This is the section to skim before shipping. Each item maps to a specific failure mode that surfaces during an on-call incident.
Setting secure token expiration windows
Set the expiry window to 15–30 minutes. Shorter windows reduce replay risk but frustrate users who don't check email immediately. National Institute of Standards and Technology (NIST) Special Publication (SP) 800-63B was superseded by SP 800-63-4, which NIST finalized on July 31, 2025 and made effective on August 1, 2025. The retired 800-63B revision doesn't address password reset link expiry directly, and the Open Worldwide Application Security Project (OWASP) Forgot Password Cheat Sheet says only that reset tokens should be single-use and expire after an appropriate period. OWASP doesn't specify an exact ceiling. 15–30 minutes is common practice.
Preventing reset token replay attacks
Mark the token as used on the first successful validation by setting used_at to the current timestamp. Reject any subsequent request that presents the same token, even if it arrives within the expiry window.
Preventing account enumeration attacks
Return the same generic response whether the account exists or not. The response must have the same HTTP status code, the same body, and the same timing characteristics. Process every request through the same pipeline: if the account exists, send the reset email, and if it does not, skip the send internally while keeping response timing identical (a fixed small delay smooths out the send-time difference). Never reveal account existence to the client.
Preventing reset email abuse
Rate limit reset requests per account and per IP. A starting point of 3–5 requests per hour is common practice, not a figure from a standard, so tune it to your traffic and abuse patterns. Log the timestamp, IP, and user agent for every reset request and completion so you can audit abuse patterns. Store these values in the ip_address and user_agent columns of the reset token table so every audit entry traces back to a specific token.
Secure links with HTTPS only
Reject any reset request that arrives over HTTP. The reset link must use HTTPS, and the reset page must set the Strict-Transport-Security header.
Handling 2FA during a password reset
Decide how the reset flow treats two-factor authentication (2FA) before an attacker makes the decision for you. An email-only reset that bypasses 2FA hands a full account takeover to anyone who compromises the inbox, which is why the reset email alone should never be enough to get back in.
- Require the second factor: When the account has 2FA enabled, ask for the authenticator code or a backup code on the reset form before you accept the new password. The reset link proves inbox access, and the second factor proves device possession.
- Don't disable 2FA silently: Keep the existing enrollment active after a reset whenever possible. If you do force re-enrollment, notify the user through the confirmation email and log the change, because silently stripping 2FA converts a partial compromise into a persistent one.
- Handle backup codes explicitly: Accept backup codes on the reset form, mark each code as used the moment it validates, and prompt the user to regenerate their code set after the reset completes.
- Cover the lost-factor path: Users who lose both their password and their authenticator need a recovery flow with stronger proof than the reset link, such as support verification or a recovery email on file, rather than a bypass built into the standard flow.
Production-ready password reset email examples
If your current setup splits outbound reset sends and inbound reply handling across two providers, send.io handles both under one API, so the reset email goes out and the user's reply or abuse report comes back under one integration instead of two.
Lightweight password reset markup
The minimal HTML template below is optimized for transactional sends where deliverability matters more than design. It uses the same table structure as the full template but strips the footer and the secondary link to reduce file size.
<!DOCTYPE html>
<html lang="en">
<head><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1.0"><title>Reset your password</title></head>
<body style="margin:0;padding:0;background-color:#f4f4f4;">
<table role="presentation" width="100%" border="0" cellspacing="0" cellpadding="0">
<tr><td align="center" style="padding:20px 0;">
<table role="presentation" width="600" border="0" cellspacing="0" cellpadding="0" style="background-color:#ffffff;border-radius:8px;">
<tr><td style="padding:40px 30px;font-family:Arial,sans-serif;font-size:16px;line-height:24px;color:#333333;">
<p style="margin:0 0 20px;font-size:24px;font-weight:bold;color:#111111;">Reset your password</p>
<p style="margin:0 0 20px;">Click the button below to reset your password. This link expires in {{EXPIRY_MINUTES}} minutes.</p>
<table role="presentation" border="0" cellspacing="0" cellpadding="0"><tr><td align="center" bgcolor="#0095ff" style="border-radius:5px;">
<a href="{{RESET_URL}}" target="_blank" style="display:inline-block;padding:14px 28px;color:#ffffff;text-decoration:none;font-weight:bold;">Reset Password</a>
</td></tr></table>
<p style="margin:30px 0 0;font-size:14px;color:#666666;">If you didn't request this, you can safely ignore this email.</p>
</td></tr>
</table>
</td></tr>
</table>
</body>
</html>
send.io's visual template editor supports creating, rendering, testing, publishing, and versioning reusable email templates, so you can iterate on the reset design and publish changes when they're ready.
Footer implementation for email flows
The footer must include the security disclaimer and a link to report abuse. The snippet below adds a suppression list hook so users who report the email as spam are automatically added to your suppression list.
<p style="margin:20px 0 0;font-family:Arial,sans-serif;font-size:12px;line-height:18px;color:#999999;">
If you didn't request this reset, <a href="https://yourapp.com/report-abuse?token=%7B%7BTOKEN_ID%7D%7D" style="color:#999999;text-decoration:underline;">let us know</a>.
</p>
Secure password reset email patterns
The send.io dispatch call includes an idempotency key so a repeated reset request returns the original result instead of sending a duplicate email. Delivery event webhooks report the outcome of each send back to your endpoint, so your auth service can suppress dead addresses and log abuse.
# Illustrative example: the base URL and API key variable names, the
# Authorization header format, and the body field names below are
# placeholders. Confirm them against the send.io API reference before use.
curl -X POST "$API_BASE_URL/emails" \
-H "Authorization: Bearer $API_KEY" \
-H "Content-Type: application/json" \
-H "Idempotency-Key: reset-user-123-20261022" \
-d '{
"from": "security@yourapp.com",
"to": "user@example.com",
"subject": "Reset your password",
"html": "...",
"text": "...",
"tags": ["password-reset"]
}'
Provision a test inbox and dispatch the reset template to it through send.io to verify rendering and delivery behavior before wiring it into production.
FAQs
How long should password reset tokens be valid?
15–30 minutes. Shorter windows reduce replay risk. Longer windows increase the attack surface.
Should old tokens be invalidated when a new one is requested?
Yes. Invalidate all outstanding tokens for the user when a new reset is requested to prevent replay attacks using old tokens.
What should the email say if the account doesn't exist?
Return the same generic response as a successful request. Do not reveal account existence.
Do password reset attempts need to be logged?
Yes. Log the timestamp, IP, and user agent for every reset request and completion.
Can the same template be used for email verification?
Yes, with a longer expiry window and different copy. The token lifecycle logic is identical.
Key terms glossary
Token hash: The SHA-256 hash of the reset token stored in the database. The raw token is sent to the user. The hash is used for validation.
Expiry window: The time period during which a reset token is valid. A window of 15–30 minutes is common practice. OWASP doesn't specify an exact ceiling.
Idempotent retry: A repeated API request with the same idempotency key returns the original result instead of sending a duplicate email.
Webhook payload: The JSON body sent by the email provider to your endpoint when a delivery event (bounce, complaint, open) occurs.
Suppression list: A list of email addresses that have hard-bounced or complained, which the provider blocks from future sends.
Plain-text fallback: The text/plain MIME part of a multipart/alternative email, required for clients that strip HTML.
Multipart/alternative: The MIME type that allows an email to contain both HTML and plain-text versions.
Enumeration attack: An attack that determines whether an account exists by observing differences in the reset response (timing, status code, or body).