By Nora Vance · Updated October 7, 2026

Password reset emails: tokens, security rules and a working send

A password reset email is a single-use link or code that proves control of a mailbox. Build it in four parts: generate a long random token and store only its hash, answer the request form identically whether or not the account exists, send a short message on a stream kept apart from marketing mail with click tracking off, and invalidate the token the moment it is used or expires. The security rules below come from OWASP's Forgot Password Cheat Sheet, and we label separately anything that is our own recommendation. The send sample follows Postmark's documentation.

What OWASP's cheat sheet requires

The Forgot Password Cheat Sheet calls this feature a common source of vulnerabilities, including user enumeration, and sets these rules for the request step:

  • Return a consistent message for existing and non-existent accounts.
  • Make the response time uniform, using asynchronous calls or by following the same logic instead of a quick exit.
  • Limit automated submissions with per-account rate limiting, a CAPTCHA or other controls, so nobody can flood a victim's inbox with thousands of requests.
  • Do not lock the account because someone used the forgot-password form, since attackers could use that to deny access to known usernames.

For the tokens themselves, OWASP says they should come from a cryptographically secure random number generator, be long enough to resist brute force, be linked to one user in the database, be stored securely as described in its Password Storage guidance, be single use, and expire after an appropriate period. It notes that JWTs can replace random tokens but add their own vulnerabilities. For URL tokens it adds that the reset URL must not rely on the Host header, which opens host header injection, so the base should be hard-coded or checked against trusted domains; the link must use HTTPS; the reset page should send a no-referrer referrer policy; and token guessing needs rate limiting.

After the user sets a new password, OWASP asks for the password to be typed twice and checked against the normal policy, stored using secure practices, and followed by an email telling the user it was reset, never containing the password. It advises against logging the user in automatically and says to ask whether they want existing sessions invalidated, or to invalidate them automatically. It also states that a user must always have some recovery path, even if that means proving identity to support staff.

Generate, store and consume the token

OWASP stops at "stored securely"; this part is our recommendation. Create 32 bytes from the operating system's random generator, encode them for a URL, email that value, and keep only a SHA-256 hash in the database. Because the token carries 256 bits of randomness, a fast hash is enough here; slow password hashing exists to protect low-entropy secrets and would only add latency. A leaked table of hashes cannot be turned back into working links.

import { randomBytes, createHash } from 'node:crypto';

const TOKEN_TTL_MS = 30 * 60 * 1000; // our choice: OWASP only says "an appropriate period"

export function hashToken(token: string): string {
  return createHash('sha256').update(token).digest('hex');
}

export function newResetToken() {
  const token = randomBytes(32).toString('base64url'); // goes in the email
  return {
    token,
    tokenHash: hashToken(token),                       // goes in the database
    expiresAt: new Date(Date.now() + TOKEN_TTL_MS),
  };
}

We chose 30 minutes as the lifetime because a reset email is usually opened within minutes, but the cheat sheet gives no number and your threat model may argue for more or less. Consume the token with one atomic statement so two clicks, or a scanner that prefetches the link, cannot both succeed:

-- consume the token atomically: a second click finds nothing to update
UPDATE password_resets
   SET used_at = now()
 WHERE token_hash = $1
   AND used_at IS NULL
   AND expires_at > now()
RETURNING user_id;

Look the token up by its hash, never by a user ID that came from the URL, and show the same generic error for unknown, expired and used tokens. When a user asks for a second reset, mark earlier unused tokens as used so only the newest link works. That rule is ours, not OWASP's, but it removes a pile of valid links sitting in old messages.

The request endpoint: same answer, same timing

The simplest way to meet the uniform-response rule is to make the request path do nothing that depends on the account. Validate the format, apply rate limits per address and per IP, queue a job, and return a fixed message. The worker then looks up the user, and if there is none it quietly stops. Nothing on the request path branches on whether the account exists, so neither the body nor the timing leaks that fact.

export async function requestReset(rawEmail: string) {
  const email = rawEmail.trim().toLowerCase();

  // no branch on "account exists" in the request path, so the answer and its timing
  // are the same for every input
  await queue.add('send-password-reset', { email });

  return { message: 'If that address has an account, a reset link is on its way.' };
}

// worker: runs later, off the request path
export async function sendPasswordReset({ email }: { email: string }) {
  const user = await db.users.findByEmail(email);
  if (!user) return;                                  // silently stop

  await db.passwordResets.invalidateOpenTokens(user.id);
  const { token, tokenHash, expiresAt } = newResetToken();
  await db.passwordResets.insert({ userId: user.id, tokenHash, expiresAt });

  // base URL is a constant, never built from the Host header
  const link = RESET_BASE_URL + '?token=' + encodeURIComponent(token);
  await sendResetEmail(user.email, link);
}

Rate limits belong in front of this handler, and they have to answer with the same message when they trigger; a distinct "too many requests" for one address reveals that the address exists. Keep a separate limit on the page that accepts tokens, because OWASP's brute-force warning applies there.

What the email itself should say

A good reset email is short enough to read on a lock screen. Say what happened (someone asked to reset the password), give one action (the link), state how long it lasts, and say what to do if the recipient did not ask. Put the full URL in plain text beneath the button, because mail clients and security gateways sometimes strip buttons. Always send a plain-text part with the HTML. Leave out the password, the token in the subject line, marketing content and attachments. Use a From address on a dedicated domain, such as security@auth. followed by your domain, so the message looks like what it is.

Subject: Reset your Acme password

Hi Ada,

Someone asked to reset the password for your Acme account.
Use the link below within 30 minutes. It works once.

https://app.acme.example/reset-password?token=...

If you did not ask for this, ignore this message. Your password has not changed.

Acme security

Link or code? OWASP lists URL tokens as the simplest and fastest option, and describes PINs of 6 to 12 digits for side channels such as SMS, which should create a limited session that can only reset the password. For email, a link is the better default. A code makes sense when the reset happens inside a mobile app without universal links; if you pick it, apply the same single-use, expiry and rate-limit rules, and cap the number of wrong guesses.

Sending: separate stream, no click tracking, no waiting

Resets are the mail users are actively waiting for, so keep them away from anything that can hurt reputation. Google's guidelines suggest keeping each category of message on its own From address and, when you use several IPs, one per message type, and Yahoo asks for segregation by IP or DKIM domain. Providers give you the tools. Postmark runs transactional and broadcast mail through separate streams, and its default transactional stream is called outbound. Resend's domain documentation recommends sending from subdomains to isolate reputation and notes that you can keep open and click tracking enabled for a newsletter while leaving it disabled for transactional emails such as password resets. Our Resend vs Postmark page compares the two models.

Turn click tracking off for these messages. Tracking rewrites each link to pass through the provider's redirect domain, which puts your secret token in someone else's logs and adds a hop that can fail; that reasoning is ours. The setting exists everywhere: Postmark has a TrackLinks option per message, Mailgun a per-message o:tracking-clicks parameter, and Resend a per-domain switch.

Send from a background worker, as in the sketch above, not inside the web request. Postmark's send guide recommends background jobs for messages with attachments, and a queue gives you retries for every kind of message. Retries bring duplicates unless you guard against them: Resend's idempotency keys make a repeated request return the original response for 24 hours, with a suggested key shape of event-type/entity-id, so password-reset/ plus the reset row's ID works. Postmark's send documentation describes no idempotency key, so keep a sent flag on the reset row instead.

The call below is Postmark's own TypeScript example from its send email guide, with the subject and bodies swapped for the reset message. The sender must be a confirmed signature or a verified domain, otherwise Postmark answers 422, and passing POSTMARK_API_TEST as the token lets you exercise the call in tests without delivering anything.

import { ServerClient } from "postmark";

export const postmark = new ServerClient(process.env.POSTMARK_SERVER_TOKEN!);

const result = await postmark.sendEmail({
  From: "security@auth.acme.example",
  To: "ada@customer.example",
  Subject: "Reset your Acme password",
  TextBody: resetText,
  HtmlBody: resetHtml,
  MessageStream: "outbound",
});

console.log({ messageId: result.MessageID });

After the reset, and when the account may be compromised

Send the confirmation email OWASP asks for as soon as the password changes: it tells the real owner that something happened and gives them a way to react. End the existing sessions, or ask the user whether to. Do not sign the person in from the reset page. OWASP also warns that a reset alone may not restore control of a compromised account, because an attacker may keep an active session or may have changed recovery details and MFA methods. In that case it recommends invalidating outstanding reset links and codes after recovery, reviewing recovery addresses and MFA methods with the verified owner, and notifying every registered address.

Watching delivery

A reset that never arrives is a support ticket and, often, a lost customer. Subscribe to bounce and complaint events from your provider. Postmark's webhooks overview describes bounce events with a RecordType and a MessageID you can match to the send; Resend's list includes email.bounced, email.complained and email.delivery_delayed. Do not tell the person on the form that the address bounced, since that would reveal that the account exists; log it and surface it in your own admin tools. Also store the provider's message ID on the reset row, because support will need it. How far back you can search depends on the plan: Postmark Basic keeps 45 days of activity, and Resend keeps 30 days on every plan.

Volume is small for most products, so cost is rarely the deciding factor. Resend's free plan covers 3,000 emails a month at up to 100 a day, and Postmark's free plan is limited to 100 a month, which is enough only for testing. See Resend pricing and Postmark pricing, and read the transactional email guide for the broader picture. Make sure the sending domain is authenticated before launch, as described in our SPF, DKIM and DMARC guide.

Test list before you ship

  1. Request a reset for an existing and a non-existent address; compare the status code, body and response time.
  2. Click a valid link twice; the second click must fail with the same generic message as an expired link.
  3. Request three resets in a row; only the newest link may work.
  4. Send a request with a forged Host header and confirm the emailed URL still uses your hard-coded base.
  5. Inspect the raw email: plain-text part present, no tracking redirect on the link, no token in the subject.
  6. Bounce a reset on purpose with your provider's test address and confirm nothing about it appears in the public response.
  7. Change the password and check that the confirmation email is sent and old sessions behave as intended.

FAQ

How long should a reset token last?

OWASP requires that tokens expire after an appropriate period and gives no figure. We use 30 minutes in the examples; shorter lowers the window for theft, longer helps users on slow mailboxes. Whatever you pick, enforce it on the server and make a second request invalidate the first.

Should the email contain a link or a code?

A link is the simplest option and OWASP calls URL tokens the simplest and fastest implementation. Use a code only when a link is awkward, such as a native app, and apply the same expiry, single-use and attempt-limit rules.

Is it safe to tell the user the address was not found?

No. OWASP's first rule is a consistent message for existing and non-existent accounts, because a different answer lets an attacker list which addresses have accounts.

Why not track clicks on the reset link?

Link tracking sends the click through the provider's redirect domain, so the token passes through a system you do not control and the link depends on one more host. Turn it off for password resets and keep it for newsletters if you want it.