Skip to content
send.io
All posts

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 border="0" on all elements so the template renders correctly in both classic Outlook and New Outlook

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 <meta name="color-scheme" content="light dark"> in <head> and override the inverted colors in a @media (prefers-color-scheme: dark) block. For backgrounds, swap a solid bgcolor for a two-stop linear-gradient(#ffffff, #ffffff), which Gmail's inverter skips. Use inline styles and keep file size under 100KB

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 #FFFFFE instead of #ffffff, so the auto-invert doesn't trigger. Separately, Apple Mail supports media queries, so you can also add a @media (prefers-color-scheme: dark) block in your <style> tag to set explicit dark-mode colors

Dark mode (varies by client)

Clients take different approaches: full color inversion, partial inversion, or no change at all

Set explicit colors with !important inside @media (prefers-color-scheme: dark) and declare a color-scheme meta tag, since clients respect declared color values more than inferred ones

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

  1. User requests reset: The client submits the account email to your /forgot-password endpoint.
  2. Server generates token: Create a token with at least 128 bits of entropy using a cryptographically secure random number generator.
  3. Server hashes token: Hash the token with Secure Hash Algorithm 256-bit (SHA-256) and store the hash in the database with an expires_at timestamp.
  4. Server sends email: Pass the raw token (not the hash) to the email template as the {{RESET_URL}} query parameter.
  5. User clicks link: The client submits the raw token to your /reset-password endpoint. Protect the reset form against cross-site request forgery (CSRF) with a per-session CSRF token and SameSite=Lax or SameSite=Strict session cookies, so a malicious page can't submit the form on the user's behalf.
  6. Server validates token: Hash the submitted token and compare it to the stored hash. Check that expires_at is in the future and used_at is null.
  7. Server invalidates token: Mark the token as used by setting used_at to the current timestamp.
  8. 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.
  9. 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

id

UUID or integer (choose based on your database and use case)

Primary key

Typically auto-generated

user_id

Same type as users.id

Foreign key to users table

Indexed, required

token_hash

CHAR(64) for a SHA-256 hash

Hashed reset token

SHA-256 hex digest (64 characters). Never store plaintext

expires_at

TIMESTAMP

Token expiration time

UTC. Index for cleanup queries

created_at

TIMESTAMP

Token creation timestamp

UTC, for audit trail

used_at

TIMESTAMP

When token was consumed

NULL until used. Prevents reuse

ip_address

VARCHAR(45)

Request IP address

Logged on every reset request, 45 characters fits IPv6

user_agent

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