Password reset, verification, and magic link emails done properly

Authentication email is the highest-stakes mail your product sends. It is the only category where a delayed or filtered message locks a paying customer out of their own account — and it is also, when built well, the best-performing mail you have.

Updated July 31, 202614 min read
  • Token design and expiry
  • Security patterns that matter
  • Latency budgets
  • Supabase, Clerk, and Auth.js

Key takeaways

  • Auth mail is the only email category where non-delivery is an outage. Budget under ten seconds end to end and measure from the user action, not the API call.
  • Tokens should be high-entropy, single-use, short-lived, and stored hashed — treat them as credentials, because they are.
  • Never reveal whether an account exists. The response to a reset request must be identical for known and unknown addresses.
  • Auth mail has the highest engagement and lowest complaint rate of anything you send, which makes it ideal warmup traffic — and worth protecting from your marketing stream.
  • Auth providers usually integrate over SMTP or an outbound hook. SMTP is faster to configure; a hook gives you full control of the message.

Password resets, email verification, magic links, and one-time codes share a property no other email has: the user is sitting there, right now, unable to proceed until the message arrives. A late receipt is a minor irritation. A late password reset is a person locked out of an account they are paying for, and it converts directly into support volume.

That makes auth mail worth more engineering care than its volume suggests. It also makes it the mail most worth protecting architecturally, because the failure modes that affect it — a shared reputation degraded by a marketing campaign, a shared IP pool degraded by a stranger — are exactly the ones you cannot see coming.

This guide covers the four message types, the token and security design underneath them, the latency budget, and how the common auth providers plug in.

The four message types

TypePurposeTypical expiryNotes
Email verificationProve the address is real and controlled by the signup24 hoursLonger window is acceptable — nobody is blocked while waiting
Password resetLet a user set a new password without the old one15–60 minutesSingle use; invalidate on use and on password change
Magic linkSign in without a password5–15 minutesIt is a live session credential — treat expiry aggressively
One-time code (OTP)Step-up verification or passwordless sign-in5–10 minutesCap attempts; bind to the originating session

The expiry column reflects a real trade-off rather than a convention. A short window limits the damage if a mailbox is later compromised, but it also means a message delayed in transit can expire before the user acts on it. Fifteen minutes is comfortable when your p95 delivery is under ten seconds; it is hostile when delivery is unpredictable. The security posture you can afford is a function of the delivery reliability you actually have.

Token design

A reset token is a credential that grants account access. It deserves the same handling as a password, and the common shortcuts — predictable values, indefinite validity, plaintext storage — turn a convenience feature into an account takeover path.

  • Generate from a cryptographically secure source with at least 128 bits of entropy. Sequential ids, timestamps, and hashes of the user id are all guessable.
  • Store the hash, not the token. Hash it before persisting, exactly as you would a password. A database disclosure should not hand over live reset tokens.
  • Enforce single use. Mark the token consumed inside the same transaction that applies the change, so a replay cannot succeed.
  • Set a short expiry and enforce it server-side. Never rely on a client to respect it.
  • Invalidate related tokens on use. Completing a reset should void every other outstanding reset token for that account.
  • Invalidate sessions after a password change. If the reset was triggered by a compromise, leaving the attacker’s session alive defeats the point.
  • Keep the token out of the query string where you can. URLs leak through referrer headers, browser history, and server logs; a path segment or a POST-based confirmation step is better.

Issuing a reset token

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

export async function createResetToken(userId: string) {
  const token = randomBytes(32).toString('base64url')          // 256 bits
  const tokenHash = createHash('sha256').update(token).digest('hex')

  await db.passwordResetToken.create({
    data: {
      userId,
      tokenHash,
      expiresAt: new Date(Date.now() + 30 * 60 * 1000),        // 30 minutes
    },
  })

  // Only the plaintext token leaves this function, and only into the email.
  return token
}
  • · Store the hash; compare by hashing the presented token on redemption.
  • · Consume the token in the same transaction that updates the password.

Security patterns that matter

Do not leak account existence

A reset form that says "no account with that email" is an account enumeration oracle: an attacker can test addresses against your user base at leisure. The response — status code, body, and visible timing — must be identical whether or not the address is registered. Say "if an account exists for that address, we have sent a reset link" and mean it.

Timing counts. If the known-account path sends an email and the unknown path returns immediately, the difference is measurable. Do the send asynchronously so both paths return at the same speed.

Rate limit on several axes

AxisPurposeReasonable starting point
Per accountStops mailbox flooding of one user3–5 requests per hour
Per IPStops enumeration sweeps10–20 requests per hour
Per address globallyStops rotation across many IPs5 requests per hour
OTP verification attemptsStops brute-forcing a six-digit code5 attempts, then invalidate the code

The last row is the one that turns a six-digit code from weak to adequate. A million possibilities is trivially brute-forceable given unlimited attempts and entirely adequate given five. The attempt cap, not the code length, is doing the security work.

Give the recipient enough context to act

A security-relevant email should tell the recipient what was requested, roughly when, and from where, plus what to do if it was not them. That is the difference between a message that helps someone notice a compromise and one that they ignore. Keep it proportionate — an approximate location and a time is useful; a full user-agent string is noise.

The latency budget

Auth mail should reach the inbox in under ten seconds. That budget is spent across several stages, and the one teams forget is usually their own.

StageTargetCommon failure
User action → job enqueued< 100msSending inline and blocking the response
Queue wait< 1sAuth mail sharing a queue with bulk work behind it
Send API call< 500msNo timeout, so a slow call blocks a worker
Provider → recipient server< 5sThrottling from a reputation problem
Recipient server → inboxImmediate to minutesGreylisting on an unfamiliar sender

The queue row is the most common self-inflicted delay. If password resets are enqueued behind a nightly digest job, the delivery time is however long the digest takes — and the provider’s metrics will look perfect throughout, because from their perspective the message arrived promptly after you sent it. Give auth mail its own priority queue, and measure from the user action so that time is visible.

Greylisting is the stage you cannot control directly. Some receivers defer the first message from an unfamiliar sender and accept the retry a few minutes later, which is precisely long enough to make a fifteen-minute magic link uncomfortable. Established sender reputation is what makes greylisting stop happening, which is another way of saying that auth mail latency is partly a deliverability problem.

Implementation

Password reset send

import { SuperSendTX } from 'supersendtx'

const client = new SuperSendTX(process.env.SUPERSENDTX_API_KEY!)

export async function sendPasswordReset({
  to,
  resetUrl,
  requestedFrom,
}: {
  to: string
  resetUrl: string
  requestedFrom: string
}) {
  return client.emails.send({
    from: 'Acme Security <security@mail.acme.com>',
    to,
    subject: 'Reset your Acme password',
    html: `
      <p>We received a request to reset your Acme password.</p>
      <p><a href="${resetUrl}">Choose a new password</a></p>
      <p>This link expires in 30 minutes and can be used once.</p>
      <p>Requested from ${requestedFrom}. If this wasn't you, no action is
         needed — your password has not changed.</p>
    `,
    text: `Reset your Acme password: ${resetUrl}

This link expires in 30 minutes and can be used once.
Requested from ${requestedFrom}. If this wasn't you, no action is needed.`,
    tags: [{ name: 'type', value: 'password_reset' }],
  })
}
  • · Send from a recognisable subdomain dedicated to transactional mail.
  • · Always include the plain-text alternative — some clients and filters prefer it.
  • · Tag by message type so you can measure auth mail separately.

Two details in that example are deliberate. The from address is on a transactional subdomain rather than the root domain, so auth mail reputation is scored separately from anything marketing sends. And the message carries no unsubscribe link, because this is genuine transactional mail — the RFC 8058 one-click requirement applies to marketing and subscribed messages, and offering to unsubscribe someone from password resets is not a feature.

Content rules for auth mail

  • The action is in the first screenful, above any imagery
  • A plain-text alternative that stands on its own
  • The expiry stated explicitly in the body
  • A "if this wasn’t you" line with clear guidance
  • No promotional content, cross-sells, or newsletter links
  • No link shorteners or third-party redirect chains
  • A reply-to that a human actually monitors

The shortener rule deserves emphasis. Link shorteners and unnecessary redirect hops are strongly associated with abuse and are routinely penalised by filters — and in a security email they also make the destination unverifiable to a cautious recipient, which is precisely the wrong signal to send in a message about account security.

Integrating with auth providers

Most products do not hand-roll authentication anymore. The hosted providers all send mail themselves by default, on their own domains and shared infrastructure, which is fine for a prototype and wrong for production — your users receive account mail from a domain that is not yours, and you have no visibility into whether it arrived.

There are two integration shapes, and the choice is mostly about how much control over the message you want.

ApproachHow it worksTrade-off
Custom SMTPPoint the provider at your SMTP credentials; they compose and sendFastest to configure; you keep their templates and get limited visibility
Outbound hookThe provider calls your endpoint; you compose and send via the APIFull control of content, templates, and event tracking; more code
  • Supabase — supports both. Custom SMTP under Authentication settings is the quick path; the Send Email hook calling an Edge Function gives you full control. Full walkthrough, plus the integration overview.
  • Clerk and Auth.js — commonly wired over SMTP credentials, or through a custom email adapter that calls the send API directly. Auth provider guide.
  • Rolled by hand — you control the trigger, so send directly from your own queue worker with the patterns above.

Monitoring auth mail specifically

Auth mail should be measured on its own, not folded into an overall email dashboard. It is a small fraction of volume and an enormous fraction of the consequences, so aggregate metrics will hide a problem in it completely.

Delivery rate

> 99.5%

auth mail only

Time to delivered

p95 < 10s

from the user action

Complaint rate

~0%

anything else is a finding

Reset completion

Track it

a drop means mail is not arriving

Reset completion rate is the most useful signal on that list because it is measured in your own product rather than in email metrics. It captures the whole path end to end, including the failure modes email metrics cannot see — a message that was delivered to the spam folder counts as delivered but never gets clicked. A sudden fall in the proportion of reset requests that end in a completed reset is a deliverability alarm, and often the earliest one you will get.

  • Auth mail tagged by type so it can be filtered separately
  • Delivery and bounce rates tracked for auth mail alone
  • Time-to-delivered measured from the user action, not the API call
  • Reset and verification completion rates monitored in-product
  • A dedicated, high-priority queue for auth sends
  • Auth mail sent from a transactional subdomain, separate from marketing

Auth mail that arrives in seconds

Send password resets, verification links, and one-time codes from your own verified domain on a transactional-only network — with delivery events so you know they arrived.

Related reading

FAQ

Frequently asked questions

How long should a password reset link be valid?

Between 15 and 60 minutes for most products, and the token must be single-use and invalidated when the password changes. A shorter window limits exposure if the mailbox is later compromised, but only works if your delivery is fast and reliable — a 15-minute link is hostile when messages routinely take five minutes to arrive.

Should password reset emails use a link or a code?

Offer both when you can. A link is fewer steps if the user opens mail on the same device; a code works when they are reading mail on a phone and signing in on a laptop, which is very common. If you send a code, cap verification attempts at around five — the attempt limit, not the code length, is what makes a six-digit code secure.

How do I stop password reset forms leaking whether an account exists?

Return an identical response for registered and unregistered addresses — same status code, same body, same visible timing. Say "if an account exists for that address, we have sent a reset link". Send the email asynchronously so the known-account path does not take measurably longer than the unknown one.

How fast should a password reset email arrive?

Under ten seconds to the inbox. Budget under 100ms to enqueue, under a second of queue wait, under 500ms for the send call, and a few seconds for delivery. The most common self-inflicted delay is auth mail sitting behind bulk jobs in a shared queue — give it a dedicated high-priority queue and measure from the user action.

Should authentication emails include an unsubscribe link?

No. Password resets, verification, and one-time codes are genuine transactional mail, and the RFC 8058 one-click unsubscribe requirement applies to marketing and subscribed messages. Offering to unsubscribe someone from mail they need in order to access their account is not a feature, and it invites exactly the opt-out you do not want.

How do I send Supabase Auth emails through my own domain?

Two options. Configure custom SMTP in Supabase Authentication settings with credentials from your email provider, which is the fastest path and keeps Supabase templates. Or enable the Send Email hook so Supabase calls an Edge Function you control, which composes and sends the message through the email API — more code, but full control over content and event tracking.

Why do my password reset emails go to spam?

Usually because auth mail shares a sending identity with marketing mail whose complaints set the reputation for both. Other common causes are missing DKIM alignment, sending from an unrecognisable domain, and link shorteners in the message body. Move auth mail to a dedicated transactional subdomain with its own DKIM key, and check domain reputation in Google Postmaster Tools.

Critical email deserves infrastructure you can name

Start on Sandbox in minutes. Move to a transactional-only Pool for production, or to Dedicated servers and IPs that we build, warm, and manage for you.