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.
- Treat magic link tokens and OTP codes as credentials: high entropy, hashed at rest, single use, short lived, attempt-capped.
- Prefer a code when users often open mail on a different device than the browser signing in; prefer a link when same-device is the common path — or offer both.
- Short expiry only works if delivery is fast. Greylisting can burn a five-minute magic link before the user clicks.
- 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 magic link or password reset is a person locked out of an account they need, 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 magic link email and email OTP in depth, the four message types, token and security design, the latency budget, and how 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.
Magic link email: flow and security
A magic link email is a one-time sign-in URL delivered to the user’s inbox. The user proves mailbox control by opening the link; your app redeems a short-lived token and creates a session. It is the same credential pattern as a password reset — with a tighter expiry, because a successful click is a full login.
- 1
Request
User submits an email address. Return the same response whether or not the account exists (no enumeration).
- 2
Issue token
Generate high-entropy token, store only a hash, set expiry (typically 5–15 minutes), bind metadata (user, optional session/device).
- 3
Send magic link email
Deliver a clear subject and first-party HTTPS URL on your transactional subdomain. State the expiry in the body.
- 4
Redeem once
Validate hash, expiry, and binding. Invalidate the token in the same transaction that creates the session. Reject replays.
- Single-use invalidation after successful redemption
- Server-enforced expiry — never trust the client
- Rate limits per account, IP, and address
- Invalidate older outstanding magic links when a newer one is issued (or document why not)
- No third-party URL shorteners or opaque redirect chains
- Avoid Gmail threading collisions — unique subjects or Reference headers so users do not click a dead older link
Email OTP: when a code beats a link
Email OTP (one-time password / one-time code) sends a short numeric or alphanumeric code the user types into the app. It is still passwordless proof of mailbox control — without requiring the click to happen on the same device as the browser.
| Choose | When it wins | Watch-outs |
|---|---|---|
| Magic link email | Same-device sign-in is common; fewer taps | Cross-device handoff; link preview bots; URL leakage in logs/history |
| Email OTP | Phone-reads-mail / laptop-signs-in is common | Brute force without attempt caps; users mistype; phishing pages that ask for the code |
| Both in one message | You want one template that covers both paths | Keep the code large and the link secondary so scanners cannot “use” the login for the user |
- Keep codes short and readable in plain text — users often dictate them across devices.
- Cap verification attempts (about five), then invalidate — the attempt limit does more security work than adding digits.
- Bind the code to the originating challenge (session or login attempt id) so a code from request A cannot satisfy challenge B.
- Rate-limit sends the same way you rate-limit magic links — mailbox flooding is still abuse.
When not to rely on magic links alone
Magic links are a strong default for many consumer and SMB products. They are the wrong sole factor in some environments.
- Shared or monitored inboxes — anyone with mailbox access gets a login (assistants, shared support aliases).
- High-assurance roles — admin, finance, or production access usually needs MFA (WebAuthn, TOTP, hardware) beyond mailbox proof.
- Regulated enterprise SSO — SAML/OIDC may be mandatory; magic link is a side door you should not open casually.
- Slow or unreliable delivery — if you cannot hit a seconds-scale latency budget, prefer longer-lived resets with clear resend UX, or OTP with a patient copy path — not five-minute links.
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 <[email protected]>',
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.
Magic link email send
export async function sendMagicLinkEmail({
to,
magicUrl,
}: {
to: string
magicUrl: string
}) {
return client.emails.send({
from: 'Acme <[email protected]>',
to,
subject: `Sign in to Acme (${new Date().toISOString().slice(0, 16)}Z)`,
html: `
<p><a href="${magicUrl}">Sign in to Acme</a></p>
<p>This link expires in 10 minutes and can be used once.</p>
<p>If you did not request this, you can ignore this email.</p>
`,
text: `Sign in to Acme: ${magicUrl}
This link expires in 10 minutes and can be used once.
If you did not request this, you can ignore this email.`,
tags: [{ name: 'type', value: 'magic_link' }],
})
}- · Unique subjects reduce Gmail threading so users do not click an older dead link.
- · Keep the URL on your first-party HTTPS host — no shorteners.
Email OTP send
export async function sendEmailOtp({
to,
otp,
}: {
to: string
otp: string
}) {
return client.emails.send({
from: 'Acme <[email protected]>',
to,
subject: 'Your Acme verification code',
html: `
<p>Your code is <strong style="font-size:1.25rem;letter-spacing:0.1em">${otp}</strong></p>
<p>It expires in 10 minutes. Do not share it.</p>
`,
text: `Your Acme verification code is ${otp}.
It expires in 10 minutes. Do not share it.`,
tags: [{ name: 'type', value: 'email_otp' }],
})
}- · Make the code obvious in plain text — many users read OTP mail on a phone.
- · Cap verification attempts in your app; the email layer cannot stop brute force alone.
Docs copies of these patterns (plus verification) live in the password reset and auth email guide. Prefer plain html / text for critical auth mail so content review stays obvious; if you author with React Email, use the Send React Email guide and keep the rendered body free of trackers and shorteners.
Two details in the password-reset 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 (signup, magic link, reset, invite). Full walkthrough, plus the integration overview.
- Clerk — turn off Delivered by Clerk, listen for
email.created, deliver withsupersendtx-clerk. Clerk email guide. - Auth.js — drop-in
supersendtx-authjsemail provider for magic-link sign-in (needs a database adapter). Auth.js guide. - Better Auth —
magicLink({ sendMagicLink })andemailOTP({ sendVerificationOTP })plugins call your send helpers. Better Auth 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
SuperSend TX is now Ranla
Same critical transactional email on the same infrastructure — signup and pricing live on ranla.ai, with an AI growth marketer included.
Related reading