Skip to main content

Blog

Password reset email: template, best practices and code

A good password reset email does five things: it says what it is in the subject line, gives one link that works once and expires soon, never contains a password, tells people what to do if they didn't ask for it, and arrives within seconds. Below is a template you can copy, the security rules behind each part, and the code to send it.

The template

Use a subject line that names your product and the action. People search their inbox for "password", so put the word in.

Plain text
Subject: Reset your Acme password

Always send a plain-text version alongside the HTML. Some people read mail as text, and a message with both parts is a normal, well-formed email.

Plain text
Hi {{name}},

Someone asked to reset the password for your Acme account ({{email}}). To choose a new password, open this link:

{{reset_url}}

The link works once and expires in {{expires_in}}.

If you didn't ask for this, you can ignore this email. Your password won't change, and no one can use this link without access to your inbox.

The Acme team

The HTML version says the same thing with a button. Inline styles are the safest choice in email clients, and the raw link under the button helps when the button doesn't render.

HTML
<!doctype html>
<html lang="en">
  <body style="margin:0;padding:24px;background:#f6f7f9;font-family:Arial,Helvetica,sans-serif;color:#1f2933;">
    <div style="max-width:480px;margin:0 auto;background:#ffffff;border-radius:8px;padding:32px;">
      <h1 style="margin:0 0 16px;font-size:20px;">Reset your password</h1>
      <p style="margin:0 0 16px;line-height:1.5;">
        Hi {{name}}, someone asked to reset the password for your Acme account
        ({{email}}).
      </p>
      <p style="margin:0 0 24px;">
        <a href="{{reset_url}}"
           style="display:inline-block;background:#111827;color:#ffffff;text-decoration:none;padding:12px 20px;border-radius:6px;font-weight:bold;">
          Choose a new password
        </a>
      </p>
      <p style="margin:0 0 16px;line-height:1.5;font-size:14px;color:#52606d;">
        The link works once and expires in {{expires_in}}. If the button doesn't work,
        paste this into your browser:<br />
        <span style="word-break:break-all;">{{reset_url}}</span>
      </p>
      <p style="margin:0;line-height:1.5;font-size:14px;color:#52606d;">
        Didn't ask for this? Ignore this email. Your password won't change.
      </p>
    </div>
  </body>
</html>

The "password changed" email

Send a second, short email after the password actually changes. If someone else reset it, this is how the real owner finds out.

Plain text
Subject: Your Acme password was changed

Hi {{name}},

The password for your Acme account ({{email}}) was changed on {{changed_at}}.

If this was you, there's nothing else to do. If it wasn't, reset your password now and contact us at {{support_email}}.

What every reset email needs

PartWhy
A subject with your product name and "password"It's the first thing people look for after clicking "Forgot password".
One reset link, single useA link that works twice can be replayed from a forwarded or leaked email.
A short expiry, stated in the emailPeople know how long they have, and an old email in an inbox stops being useful to an attacker. An hour or less is common.
The account's email addressSomeone with more than one account can tell which one this is about.
"Didn't ask for this?" textUnexpected reset emails are alarming. Tell people that ignoring it is safe.
No password, everNever send the old password or a new one. Send a link or a one-time code.
Plain-text and HTML partsThe text part is a fallback for clients that don't show HTML.

The security rules behind it

The email is the visible part of a reset flow. Most of the security is in what your app does before and after sending it. The OWASP Forgot Password Cheat Sheet is the reference. These are the rules that shape the email and the link in it:

  • Give the same response whether or not the account exists, and take the same time to give it. "If an account exists for that address, we've sent a link" stops people from using your form to find out who has an account. Send the email in the background so the response time doesn't give it away either.
  • Generate the token with a secure random generator and make it long, such as 32 random bytes. Never derive it from the user ID, email or time.
  • Store a hash of the token, not the token. If your database leaks, the reset links in people's inboxes stay useless.
  • Make tokens single-use and short-lived. Mark the token used when the password changes, and replace any earlier unused token when someone asks again.
  • Build the link from a configured URL, never from the request's Host header. Otherwise an attacker can send a reset request with their own host and get a link to their site emailed to your user.
  • Set Referrer-Policy: no-referrer on the reset page. The token is in the URL, and you don't want it sent to any third-party script or image the page loads.
  • Rate-limit reset requests per account and per IP address. Otherwise your form can be used to flood someone's inbox.
  • Don't change anything until the link is used. Requesting a reset shouldn't lock the account or change the password.
  • After the reset, don't log people in automatically, and end their other sessions (or ask whether to). If someone else had access, this is what removes it.

Sending it from Node.js

This example uses the Sendaro Node.js SDK. It creates a token, stores only its hash, and sends both parts of the email. db stands for your own data layer, and renderResetHtml and renderResetText fill in the templates above.

TypeScript
import { createHash, randomBytes } from "node:crypto";
import { Sendaro } from "sendaro";

const sendaro = new Sendaro(); // reads SENDARO_API_KEY

// From your config, never from the request's Host header.
const APP_URL = "https://app.example.com";

const hash = (token: string) => createHash("sha256").update(token).digest("hex");

export async function sendPasswordReset(user: { id: string; email: string; name: string }) {
  const token = randomBytes(32).toString("base64url");

  // Store only the hash, valid for one hour. This replaces any earlier unused token.
  const reset = await db.passwordResets.replaceForUser(user.id, {
    tokenHash: hash(token),
    expiresAt: new Date(Date.now() + 60 * 60 * 1000),
  });

  const resetUrl = `${APP_URL}/reset-password?token=${token}`;
  const fields = { name: user.name, email: user.email, resetUrl, expiresIn: "1 hour" };

  await sendaro.send(
    {
      from: "Acme <security@mail.example.com>",
      to: user.email,
      subject: "Reset your Acme password",
      html: renderResetHtml(fields),
      text: renderResetText(fields),
    },
    // If a timeout makes you retry, the same key won't send a second email.
    { idempotencyKey: `password-reset-${reset.id}` },
  );
}

When the link is opened, hash the token from the URL and look it up. Accept it only if it exists, hasn't expired and hasn't been used. Then mark it used, set the new password, end the user's sessions and send the "password changed" email. Run the database steps in one transaction, so two clicks on the same link can't both succeed.

TypeScript
export async function resetPassword(token: string, newPassword: string) {
  const userId = await db.transaction(async (tx) => {
    const reset = await tx.passwordResets.findUnusedByHash(hash(token));
    if (!reset || reset.expiresAt < new Date()) {
      throw new Error("This reset link is invalid or has expired.");
    }
    await tx.passwordResets.markUsed(reset.id);
    await tx.users.setPassword(reset.userId, newPassword); // store it hashed, e.g. with argon2
    await tx.sessions.deleteAllForUser(reset.userId);
    return reset.userId;
  });

  await sendPasswordChangedEmail(userId);
}

Not on Node.js? The same send is one POST /api/emails request with a bearer token, from any language. See the API reference.

Getting it into the inbox

A reset email that lands in spam is the same as one that never arrived. People will request it again, and then contact support.

  • Send from your own domain, with DKIM, SPF and DMARC records in place. Mail from a domain that fails authentication is far more likely to be filtered. Sendaro shows the exact records to add when you verify a domain.
  • Keep auth emails apart from marketing. A sending subdomain such as mail.example.com used only for account emails keeps their reputation separate from newsletters.
  • Use your real domain in the link. Link shorteners and mismatched domains look like phishing to filters and to people.
  • Turn off click tracking for auth emails if your email provider has it. Tracking rewrites the link through the provider's domain, so the reset token passes through a third party and the link no longer matches your domain.
  • Watch for bounces. If a reset email hard-bounces, the address is wrong or no longer exists, and sending to it again won't help. Sendaro adds hard bounces to your suppression list automatically and can tell your app through a webhook.

Questions

How long should a password reset link last?

Long enough for someone to switch to their inbox and click it, and no longer. An hour or less is common. Whatever you choose, say it in the email.

Should I send a link or a code?

A link is easiest on the same device. A short one-time code is easier when someone requests the reset on one device and reads email on another, such as a TV app. Both need the same rules: random, single use, short expiry and rate limits.

Why isn't my password reset email arriving?

Check, in order: that the address is right and not on your suppression list, that your sending domain passes DKIM, SPF and DMARC, and whether the message bounced. In Sendaro, every email has a status and an event history you can look up by its ID.

Should the email say whether the account exists?

No. Show the same message on the "forgot password" page whether or not the address has an account, and only send the email when it does.

Send your first email today.

Free includes 3,000 emails every month. No card needed.