SuperSend TX is now Ranla. Same critical transactional email — plus an AI growth marketer for lifecycle campaigns. Visit ranla.ai →

Password reset, magic link, and OTP emails done properly

Magic link email and email OTP are the highest-stakes passwordless mail you send: the user is blocked until the message arrives. Build them like credentials — short expiry, single use, fast delivery — and choose link vs code based on how people actually sign in across devices.

Updated August 4, 202616 min read
  • Magic link vs email OTP
  • Token design and expiry
  • Latency vs short-lived links
  • Auth.js, Better Auth, Supabase

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

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.

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.

ChooseWhen it winsWatch-outs
Magic link emailSame-device sign-in is common; fewer tapsCross-device handoff; link preview bots; URL leakage in logs/history
Email OTPPhone-reads-mail / laptop-signs-in is commonBrute force without attempt caps; users mistype; phishing pages that ask for the code
Both in one messageYou want one template that covers both pathsKeep 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.

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 <[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-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 (signup, magic link, reset, invite). Full walkthrough, plus the integration overview.
  • Clerk — turn off Delivered by Clerk, listen for email.created, deliver with supersendtx-clerk. Clerk email guide.
  • Auth.js — drop-in supersendtx-authjs email provider for magic-link sign-in (needs a database adapter). Auth.js guide.
  • Better AuthmagicLink({ sendMagicLink }) and emailOTP({ 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

FAQ

Frequently asked questions

What is a magic link email?

A magic link email is a passwordless sign-in message that contains a one-time HTTPS URL. The user proves they control the mailbox by opening the link; your app validates a short-lived token and creates a session. Treat the token like a credential: high entropy, hashed at rest, single use, and expired in minutes — not hours.

Should I use a magic link or an email OTP code?

Use a magic link when users usually open mail on the same device they are signing in on. Use email OTP when they often read mail on a phone while signing in on a laptop. Offering both in one message covers both paths: show the code prominently in plain text and keep the link as a same-device shortcut.

How long should a magic link be valid?

Typically five to fifteen minutes, single use. Shorter is safer only if your p95 delivery is measured in seconds. Greylisting or a shared send queue can burn a five-minute link before the user clicks — pair short expiry with a dedicated high-priority queue and a clear “request a new link” path for expiry.

How do I keep email OTP codes from being brute-forced?

Cap verification attempts (about five) and invalidate the code when the cap is hit. Rate-limit how often new codes can be requested per account and IP. Bind each code to the originating login challenge so a code from an older request cannot satisfy a newer one. Digits alone are not enough without attempt limits.

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.

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 magic link or 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, magic links, 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.

How do I send Auth.js magic links through SuperSend TX?

Install supersendtx-authjs, set AUTH_SUPERSENDTX_KEY (or SUPERSENDTX_API_KEY), add SuperSendTX({ from }) to your Auth.js providers, and use a database adapter so tokens persist. Users sign in with email; SuperSend TX delivers the magic link from your verified domain. See the Auth.js guide for the full setup.

Why do my password reset or magic link 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.

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.