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
| Type | Purpose | Typical expiry | Notes |
|---|---|---|---|
| Email verification | Prove the address is real and controlled by the signup | 24 hours | Longer window is acceptable — nobody is blocked while waiting |
| Password reset | Let a user set a new password without the old one | 15–60 minutes | Single use; invalidate on use and on password change |
| Magic link | Sign in without a password | 5–15 minutes | It is a live session credential — treat expiry aggressively |
| One-time code (OTP) | Step-up verification or passwordless sign-in | 5–10 minutes | Cap 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
| Axis | Purpose | Reasonable starting point |
|---|---|---|
| Per account | Stops mailbox flooding of one user | 3–5 requests per hour |
| Per IP | Stops enumeration sweeps | 10–20 requests per hour |
| Per address globally | Stops rotation across many IPs | 5 requests per hour |
| OTP verification attempts | Stops brute-forcing a six-digit code | 5 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.
| Stage | Target | Common failure |
|---|---|---|
| User action → job enqueued | < 100ms | Sending inline and blocking the response |
| Queue wait | < 1s | Auth mail sharing a queue with bulk work behind it |
| Send API call | < 500ms | No timeout, so a slow call blocks a worker |
| Provider → recipient server | < 5s | Throttling from a reputation problem |
| Recipient server → inbox | Immediate to minutes | Greylisting 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-tothat 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.
| Approach | How it works | Trade-off |
|---|---|---|
| Custom SMTP | Point the provider at your SMTP credentials; they compose and send | Fastest to configure; you keep their templates and get limited visibility |
| Outbound hook | The provider calls your endpoint; you compose and send via the API | Full 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