---
title: "Password Reset Email: Best Practices and Template"
description: "How to build a secure password reset email: token design, expiry, rate limiting, link vs code, subject lines and copy, a full HTML and plain-text template, sending code and deliverability tips."
canonical: "https://reloop.sh/blog/password-reset-email-best-practices"
last-updated: "2026-10-26"
---

# Password Reset Email: Best Practices and Template

> For the site documentation index, see [llms.txt](/llms.txt). Docs index: [llms-docs.txt](/llms-docs.txt). Marketing corpus: [llms-full.txt](/llms-full.txt). Docs corpus: [llms-full-docs.txt](/llms-full-docs.txt). Product skill: [skill.md](/skill.md). Prefer markdown URLs (append `.md`) when available.

> How to build a secure password reset email: token design, expiry, rate limiting, link vs code, subject lines and copy, a full HTML and plain-text template, sending code and deliverability tips.
Source: https://reloop.sh/blog/password-reset-email-best-practices
Published: 2026-10-26
**A good password reset email arrives in seconds, contains one link that works once and expires within an hour, and does not reveal whether an account exists.** Everything else, from copy to design, comes after those three rules.

Password resets are the email users need most urgently and trust least. If one is slow, lands in spam or looks like phishing, the user gives up or opens a support ticket. If it is built carelessly, it becomes the easiest way to take over an account.

This guide covers the security side (tokens, expiry, rate limits), the email itself (subject, copy, a full template) and how to make sure it reaches the inbox.

---

## The password reset flow

1. User enters their email on a "Forgot password" form.
2. Your server **always** shows the same message: "If an account exists, we sent a link."
3. If the account exists, generate a token, store its **hash**, and email the link.
4. User clicks the link and sets a new password.
5. Server checks the token, updates the password, **deletes the token** and ends other sessions.
6. Send a **"Your password was changed"** confirmation.

Steps 2, 5 and 6 are the ones most implementations skip.

---

## Security best practices

### 1. Generate tokens with a secure random source

Use at least 32 random bytes from a cryptographically secure generator. Never use `Math.random()`, timestamps, user IDs or anything an attacker could guess.

```typescript

  const token = randomBytes(32).toString("base64url"); // goes in the email
  const tokenHash = createHash("sha256").update(token).digest("hex"); // goes in the DB
  return { token, tokenHash };
}
```

### 2. Store only the hash

Treat reset tokens like passwords. If your database leaks, plain-text tokens let an attacker reset any account with a pending request. A SHA-256 hash is enough here because the token is already high-entropy, so you do not need bcrypt.

```sql
CREATE TABLE password_reset_tokens (
  id          uuid PRIMARY KEY DEFAULT gen_random_uuid(),
  user_id     uuid NOT NULL REFERENCES users(id) ON DELETE CASCADE,
  token_hash  text NOT NULL UNIQUE,
  expires_at  timestamptz NOT NULL,
  used_at     timestamptz
);
```

### 3. Expire tokens quickly

**15 to 60 minutes** is the usual range. OWASP recommends short-lived tokens, and most users click within a few minutes. Show the expiry in the email so users know to act.

### 4. Make tokens single-use

Mark the token used (or delete it) in the same transaction that updates the password. Also delete every other outstanding reset token for that user, so older emails stop working.

### 5. Do not reveal whether an account exists

Return the same response, with the same status code and roughly the same timing, for known and unknown addresses. Otherwise your reset form tells attackers which emails have accounts.

To keep the timing the same, put the send on a background queue instead of awaiting it in the request.

<Callout type="info">
  Optional but useful: when the address has no account, send a short "Someone asked to reset a password for this address, but there is no account" email. It helps users who signed up with a different address. Rate-limit it hard so it cannot be used to spam strangers.
</Callout>

### 6. Rate limit requests

Limit by **email address** (for example 3 requests per hour) and by **IP** (for example 20 per hour). This prevents mailbombing a victim and protects your sender reputation. Every reset email that goes unopened counts against you.

### 7. End other sessions after a reset

A reset often happens because someone else has the password. Revoke existing sessions and refresh tokens, and offer to keep only the current device signed in.

### 8. Never email the password itself

Do not send a new temporary password, and never send the old one. Email is not a secure channel, and it sits in inboxes for years.

---

## Link or code?

| | Magic link | 6-digit code |
|---|-----------|--------------|
| **UX on same device** | One click | Copy and paste |
| **UX across devices** (request on laptop, email on phone) | Opens on the wrong device | Works anywhere |
| **Mobile apps** | Needs universal links or deep links | Simple text field |
| **Security** | Long random token, hard to guess | Short, so needs strict attempt limits |
| **Email scanners** | Corporate link scanners can "click" it | Not affected |

**Use a link** for web apps. **Use a code** for mobile apps or when users often switch devices. A code must expire faster (10 to 15 minutes) and lock after about 5 wrong attempts.

### Link scanners and single-use tokens

Microsoft Defender, Mimecast and other security gateways open links before the user does. If a **GET** request to your reset URL consumes the token, the user's click fails. Make the link open a page with the new password form, and consume the token only when that form is **submitted** (POST).

---

## Writing the email

### Subject lines

Keep them plain and recognisable. Users search their inbox for "reset" and "password".

- `Reset your Acme password`
- `Your Acme password reset link`
- `Reset your password (expires in 30 minutes)`

Avoid urgency and alarm, like `URGENT: Account security action required`. That is what phishing looks like, and spam filters agree.

### Copy that works

```
Hi Ada,

We received a request to reset the password for your Acme account
(ada@example.com).

[Reset password]

This link expires in 30 minutes and can only be used once.

If you did not ask for this, you can ignore this email. Your password
will not change.

Requested from Chrome on macOS, Berlin, Germany, at 14:32 UTC.
```

What makes it work:

- **Names the product and the account email.** Users with several accounts know which one.
- **One clear button.** No newsletter links, social icons or upsells.
- **States the expiry.**
- **Tells users what to do if it was not them.** Reassure them: ignoring the email is safe.
- **Shows request details** (device, approximate location, time). This helps users spot attacks.
- **Includes the raw URL** as a fallback for clients that break buttons.

---

## Password reset email template

This template uses Reloop's `{{{variable}}}` placeholders, so you can paste it into a [Reloop template](/docs/learn/templates) and send it by ID. It uses tables and inline styles, so it renders in Outlook and Gmail. You can also preview and edit it in the [email HTML editor](/tools/email-html-editor).

```html
<!DOCTYPE html>
<html lang="en">
  <head>
    <meta charset="utf-8" />
    <meta name="viewport" content="width=device-width, initial-scale=1" />
    <title>Reset your password</title>
  </head>
  <body style="margin:0;padding:0;background:#f4f4f5;font-family:-apple-system,Segoe UI,Roboto,Helvetica,Arial,sans-serif;">
    <table role="presentation" width="100%" cellpadding="0" cellspacing="0" style="background:#f4f4f5;padding:32px 16px;">
      <tr>
        <td align="center">
          <table role="presentation" width="100%" cellpadding="0" cellspacing="0" style="max-width:480px;background:#ffffff;border-radius:8px;padding:32px;">
            <tr>
              <td style="font-size:20px;font-weight:600;color:#18181b;padding-bottom:16px;">
                Reset your {{{product_name}}} password
              </td>
            </tr>
            <tr>
              <td style="font-size:15px;line-height:24px;color:#3f3f46;padding-bottom:24px;">
                Hi {{{first_name}}}, we received a request to reset the password
                for <strong>{{{email}}}</strong>. Click the button below to choose
                a new one.
              </td>
            </tr>
            <tr>
              <td style="padding-bottom:24px;">
                <a href="{{{reset_url}}}" style="display:inline-block;background:#18181b;color:#ffffff;text-decoration:none;font-size:15px;font-weight:600;padding:12px 24px;border-radius:6px;">
                  Reset password
                </a>
              </td>
            </tr>
            <tr>
              <td style="font-size:14px;line-height:22px;color:#52525b;padding-bottom:16px;">
                This link expires in {{{expires_in}}} and can only be used once.
                If you did not ask for a reset, ignore this email. Your password
                will not change.
              </td>
            </tr>
            <tr>
              <td style="font-size:13px;line-height:20px;color:#71717a;padding-bottom:16px;">
                Requested from {{{request_device}}}, {{{request_location}}}.
              </td>
            </tr>
            <tr>
              <td style="font-size:12px;line-height:18px;color:#a1a1aa;word-break:break-all;">
                Button not working? Paste this link into your browser:<br />
                {{{reset_url}}}
              </td>
            </tr>
          </table>
        </td>
      </tr>
    </table>
  </body>
</html>
```

And the plain-text version:

```
Reset your {{{product_name}}} password

Hi {{{first_name}}}, we received a request to reset the password for {{{email}}}.

Reset your password: {{{reset_url}}}

This link expires in {{{expires_in}}} and can only be used once.
If you did not ask for a reset, ignore this email. Your password will not change.

Requested from {{{request_device}}}, {{{request_location}}}.
```

If you leave out the text part, Reloop generates one from the HTML so the message still goes out as `multipart/alternative`. Writing your own gives you control over the wording.

<Callout type="warning">
  Template variables are inserted as-is, without HTML escaping. Escape user-controlled values such as `first_name` before you pass them, or someone can sign up with a name containing HTML or a fake link.
</Callout>

---

## Sending the email

With the [Reloop Node.js SDK](/blog/send-email-nodejs) (`reloop-email` 2.3 or later):

```typescript

const reloop = new Reloop({ apiKey: process.env.RELOOP_API_KEY ?? "" });

  to: string;
  firstName: string;
  token: string;
  device: string;
  location: string;
}): Promise<void> {
  const resetUrl = `https://app.acme.com/reset-password?token=${params.token}`;

  const { emailError } = await reloop.mail.send({
    from: "Acme <security@auth.acme.com>",
    to: params.to,
    subject: "Reset your Acme password",
    template: {
      id: process.env.RELOOP_RESET_TEMPLATE_ID ?? "",
      variables: {
        product_name: "Acme",
        first_name: escapeHtml(params.firstName),
        email: params.to,
        reset_url: resetUrl,
        expires_in: "30 minutes",
        request_device: escapeHtml(params.device),
        request_location: escapeHtml(params.location),
      },
    },
    tags: [{ name: "type", value: "password_reset" }],
  });

  if (emailError) {
    // Log it, but do not tell the user. The response must look the same either way.
    console.error("Password reset email failed", emailError.status, emailError.body.message);
  }
}

function escapeHtml(value: string): string {
  return value
    .replace(/&/g, "&amp;")
    .replace(/</g, "&lt;")
    .replace(/>/g, "&gt;")
    .replace(/"/g, "&quot;");
}
```

The SDK returns `{ response, emailError }` instead of throwing, so a failed send never crashes the request.

**Retrying from a queue?** Send the request with an [`Idempotency-Key` header](/docs/api/idempotency) based on the token's database ID, for example `password-reset-<token id>`. A retry then returns the original result instead of sending a second email. A new reset request creates a new token, so it gets a new key and a new email.

---

## Make sure it reaches the inbox

A reset email that lands in spam is the same as no email.

1. **Authenticate your domain.** SPF, DKIM and an aligned DMARC record are required by Gmail, Yahoo and Outlook. See the [setup guide](/blog/spf-dkim-dmarc-setup-guide) and the [bulk sender requirements](/blog/gmail-yahoo-bulk-sender-requirements).
2. **Use a dedicated transactional subdomain.** Send security mail from something like `auth.acme.com`, separate from newsletters on `news.acme.com`. A bad campaign then cannot drag your reset emails into spam. See [subdomain vs root domain](/docs/guides/subdomain-vs-root).
3. **Turn off click tracking for security mail.** Click tracking rewrites links through a redirect domain. That makes the link look less trustworthy, adds a hop, and puts the token in another system's logs. In Reloop, click and open tracking are set per domain on the domain's **Configuration** tab, so turn them off on your auth subdomain ([domain settings](/docs/learn/domain)).
4. **Do not use link shorteners.** bit.ly, tinyurl.com and similar links are a classic phishing signal. Reloop rejects sends that contain them with `Disallowed URL in email body`. Link straight to your own domain.
5. **No unsubscribe header.** Password resets are transactional and exempt from one-click unsubscribe rules. An unsubscribe link here only lets users block mail they need.
6. **Keep it short and image-free.** One button, a fallback link and almost no images. Large images and many links look like marketing.
7. **Send in under 10 seconds.** Users request a second reset after about 30 seconds, and then the first link no longer works. Use a fast queue and monitor delivery time.
8. **Suppress hard bounces.** Reloop keeps a [suppression list](/docs/guides/suppression-list) per organization so you do not keep sending to dead addresses.

Before you launch, send one through the [deliverability tester](/tools/deliverability-tester) to check authentication and spam signals.

---

## The confirmation email

After the password changes, send a second email to the **same address**:

```
Subject: Your Acme password was changed

Hi Ada,

The password for your Acme account (ada@example.com) was changed on
26 October 2026 at 14:35 UTC from Chrome on macOS, Berlin, Germany.

If this was you, you don't need to do anything.

If it wasn't you, secure your account now: https://app.acme.com/account-recovery
```

This is how users find out their account was taken over. Send it even when the change came from the settings page rather than a reset link. When the email address itself changes, send the notice to the **old** address too.

---

## Password reset email not received? Checklist

- [ ] Request reached your server (check logs, rate limiter and bot protection)
- [ ] Account lookup used normalised email (lowercase, trimmed)
- [ ] Send succeeded (check the [email logs](/docs/learn/logs))
- [ ] Address is not on the suppression list from a past bounce
- [ ] Domain authenticated, test with the [auth checker](/tools/auth-checker)
- [ ] Not in spam or the Promotions tab
- [ ] Corporate gateway did not quarantine it (ask the user's IT team)
- [ ] Token was not consumed by a link scanner (consume on POST, not GET)

---

## FAQ

<AccordionGroup>
  <Accordion title="How long should a password reset link be valid?">
    Between 15 and 60 minutes is the common range. Shorter is safer, but too
    short frustrates users who check email later. 30 minutes is a good
    default. Codes should expire faster, around 10 to 15 minutes.
  </Accordion>

  <Accordion title="Should the reset email say if the account does not exist?">
    Not on the website. The form should show the same message for every
    address. You can optionally email the address to say no account exists,
    because only the owner of that inbox sees it.
  </Accordion>

  <Accordion title="Why does my reset link say expired when users click it right away?">
    Usually a link scanner opened it first and your server consumed the token
    on a GET request. Show the password form on GET and only consume the token
    when the form is submitted. A second cause is users requesting the email
    twice, which invalidates the first link.
  </Accordion>

  <Accordion title="Do password reset emails need an unsubscribe link?">
    No. They are transactional messages that the user asked for. Gmail and
    Yahoo require one-click unsubscribe only for marketing and subscribed mail.
  </Accordion>

  <Accordion title="Should I hash password reset tokens?">
    Yes. Store a SHA-256 hash and compare against it, the same way you would
    never store plain passwords. A leaked database should not give an attacker
    working reset links.
  </Accordion>

  <Accordion title="Can I send password reset emails from Supabase or Firebase through my own provider?">
    Yes. Both let you set custom SMTP credentials for auth emails. Point them at
    Reloop's SMTP relay so reset emails come from your own authenticated
    domain instead of a shared default sender.
  </Accordion>
</AccordionGroup>

---

<Cta
  title="Send password resets that arrive in seconds"
  description="Templates with variables, per-domain tracking controls, idempotent sends and a free tier of 6,000 emails a month."
  href="/dashboard/signup"
  label="Get started"
/>