Password reset email best practices
Password reset emails, email verification, and magic links are the highest-stakes transactional mail your product sends. A delayed or filtered message locks someone out of their own account. Send them with POST /emails (or the npm SDK) from a verified domain.
This guide covers a production-ready password reset email flow: send examples, copy patterns, security habits, templates, and how to wire auth providers (Supabase, Clerk, Auth.js, Better Auth).
For the deeper product architecture (tokens, latency budgets, stream isolation), see the Learn guide Password reset, verification, and magic links.
Recommended flow
- Create an API key in the dashboard
- Verify your sending domain (Domains) — prefer a transactional subdomain such as
mail.yourdomain.comortx.yourdomain.com - When your app issues a reset or verify token, call Ranla with the link in
htmlandtext - Track delivery, bounce, and complaint events via webhooks
Treat provider acceptance (200 + message id) as “queued,” not “in the inbox.” Wire webhooks if support needs a delivery timeline.
Password reset email example
import { Ranla } from '@supersend/ranla'
const client = new Ranla(process.env.RANLA_API_KEY)
export async function sendPasswordResetEmail({
to,
resetUrl,
expiresInMinutes = 30,
}: {
to: string
resetUrl: string
expiresInMinutes?: number
}) {
return client.emails.send({
from: '[email protected]',
to,
subject: 'Reset your password',
html: `
<p>We received a request to reset your password.</p>
<p><a href="${resetUrl}">Reset password</a></p>
<p>This link expires in ${expiresInMinutes} minutes. If you did not request a reset, you can ignore this email.</p>
`.trim(),
text: `We received a request to reset your password.\n\nReset password: ${resetUrl}\n\nThis link expires in ${expiresInMinutes} minutes. If you did not request a reset, you can ignore this email.`,
})
}curl:
curl -X POST https://api.ranla.ai/emails \
-H "Authorization: Bearer $RANLA_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"from": "[email protected]",
"to": "[email protected]",
"subject": "Reset your password",
"html": "<p>We received a request to reset your password.</p><p><a href=\"https://yourapp.com/reset?token=…\">Reset password</a></p><p>This link expires in 30 minutes. If you did not request a reset, you can ignore this email.</p>",
"text": "We received a request to reset your password.\n\nReset password: https://yourapp.com/reset?token=…\n\nThis link expires in 30 minutes. If you did not request a reset, you can ignore this email."
}'Copy habits that help inbox placement
- Use a specific subject (
Reset your password) — avoid vague “Action required” - Include a plain-text part every time
- Put the reset URL in both the button/link and as visible text for clients that strip HTML
- State expiry clearly
- Never attach marketing modules or unsubscribe footers to auth mail
Email verification example
await client.emails.send({
from: '[email protected]',
to: user.email,
subject: 'Verify your email',
html: `<p>Confirm your email address:</p><p><a href="${verifyUrl}">Verify email</a></p><p>If you did not create an account, you can ignore this email.</p>`,
text: `Confirm your email address: ${verifyUrl}\n\nIf you did not create an account, you can ignore this email.`,
})Magic link and OTP examples
Magic links are the same send pattern with a shorter-lived URL:
await client.emails.send({
from: '[email protected]',
to,
subject: 'Your sign-in link',
html: `<p><a href="${magicUrl}">Sign in</a></p><p>This link expires in 10 minutes.</p>`,
text: `Sign in: ${magicUrl}\n\nThis link expires in 10 minutes.`,
})One-time codes should stay short and readable in plain text (users often read OTP mail on a second device):
await client.emails.send({
from: '[email protected]',
to,
subject: 'Your verification code',
html: `<p>Your code is <strong>${otp}</strong>.</p><p>It expires in 10 minutes.</p>`,
text: `Your code is ${otp}.\n\nIt expires in 10 minutes.`,
})Security habits (application side)
Ranla delivers the message; your app owns the token.
- Generate high-entropy, single-use tokens and store them hashed
- Keep expiry short (minutes for magic links / OTP; tens of minutes for password reset)
- Return the same response whether or not the account exists (no enumeration)
- Prefer first-party HTTPS links — avoid third-party URL shorteners in auth mail
- Make sends idempotent so retries do not create three reset emails
Latency and deliverability
Auth mail should target seconds from user action to inbox. Budget queue time in your workers, not only the HTTP round trip to the API.
- Authenticate the sending domain with SPF, DKIM, and DMARC before production traffic (Domains)
- Keep auth mail on a transactional identity — do not send campaigns from the same subdomain
- Suppress hard bounces immediately so retries do not burn reputation (Suppressions)
If resets land in spam, diagnose authentication and reputation before rewriting subject lines — see Why emails go to spam.
Templates
For repeated auth copy, store HTML in Templates and send by alias with variables (reset_url, expires_in) instead of inlining markup in every service. Keep a plain-text body on the send call or in the template so filters and accessibility stay covered.
Auth providers (Supabase, Clerk, Auth.js, Better Auth)
Those products often expect custom SMTP or an email hook. Ranla supports both:
| Provider | Typical path | Guide |
|---|---|---|
| Supabase | Auth hook → POST /emails, or custom SMTP |
Supabase Auth email |
| Clerk | email.created webhook → supersendtx-clerk |
Clerk email |
| Auth.js / NextAuth | supersendtx-authjs email provider |
Auth.js email |
| Better Auth | sendResetPassword / sendVerificationEmail |
Better Auth email |
Overview of the shared pattern: Auth provider email. SMTP relay details: SMTP.