Email & SMS
Deliver password-reset codes with your own transporter, sender and templates.
Setup#
The auth router sends one-time codes for password reset. Give it a way to send email — a nodemailer transporter is the quickest — and /password/forgot starts working.
npm install nodemailer
import nodemailer from 'nodemailer';
createAuthRouter({
model: User,
token: { secret: process.env.JWT_SECRET },
appName: 'Acme',
mail: {
transporter: nodemailer.createTransport(process.env.SMTP_URL),
from: 'Acme <no-reply@acme.com>',
},
});
Or set SMTP_URL and MAIL_FROM in .env and leave mail out — see .env examples.
POST /api/auth/password/forgot { "identifier": "ada@example.com" }
POST /api/auth/password/reset { "identifier": "ada@example.com", "code": "402913", "newPassword": "…" }
How a reset works#
Three calls. The library generates the code, stores it safely and sends it through the channel you configured; your app only decides how mail and SMS leave the building.
# 1. Ask for a code. The answer is the same whether or not the account exists.
POST /auth/password/forgot { "identifier": "ada@example.com", "channel": "email" }
→ { "success": true, "message": "If that account exists, a code has been sent." }
# 2. The code arrives by email (or SMS), rendered from your template.
# 3. Spend it.
POST /auth/password/reset { "identifier": "ada@example.com",
"code": "402913", "newPassword": "…" }
The code is stored as an HMAC under your token secret, never in the clear; it is bound to its purpose, expires (10 minutes by default), and is counted — five wrong guesses and it is dead. A successful reset also signs out every refresh-token session.
| What you configure | What happens |
|---|---|
mail.transporter or SMTP_* | Codes go by email through nodemailer (or anything with sendMail). |
mail.sender | Your function sends the email — Resend, SES, Postmark, a queue. |
sms.sender | Codes can go by SMS. There is no default SMS provider. |
| Nothing | Codes are never generated, and a warning is logged. |
Common options#
| Option | What it does |
|---|---|
mail.transporter | A nodemailer transporter (or anything with sendMail). |
mail.from | Sender address. Or MAIL_FROM. |
mail.sender | Your own send function — Resend, SES, a queue. Replaces the transporter. |
mail.templates | Your own subject, text and HTML. |
sms.sender | Turns on SMS codes — Twilio, Vonage, SNS… |
sms.templates | Your own SMS text. |
Swap the sender#
A sender is one function. Give mail.sender and the transporter is not used; give
sms.sender and SMS becomes available.
import { Resend } from 'resend';
import twilio from 'twilio';
const resend = new Resend(process.env.RESEND_API_KEY);
const sms = twilio(process.env.TWILIO_SID, process.env.TWILIO_TOKEN);
createAuthRouter({
model: User,
identifiers: ['email', 'phone'],
token: { secret: process.env.JWT_SECRET },
mail: {
sender: async ({ to, subject, text, html }) =>
resend.emails.send({ from: 'Acme <no-reply@acme.com>', to, subject, text, html }),
},
sms: {
sender: async ({ to, text }) =>
sms.messages.create({ to, from: process.env.TWILIO_FROM, body: text }),
},
});
| Sender | Receives |
|---|---|
mail.sender | { to, subject, text, html, user, code, purpose, req } |
sms.sender | { to, text, user, code, purpose, req } |
user is the public user — no password or other secrets. A sender that throws makes the
request a 503 "Could not send the code", and the error is logged.
Custom templates#
A template is a string with placeholders, or a function that builds the message. Anything you don't override keeps the built-in template.
| Placeholder | Value |
|---|---|
{{code}} | The one-time code |
{{minutes}} | Minutes until it expires |
{{appName}} | appName option or APP_NAME |
{{user.name}}, {{user.email}}, … | Any public field of the user |
mail: {
transporter,
from: 'Acme <no-reply@acme.com>',
templates: {
passwordReset: {
subject: 'Reset your {{appName}} password',
text: 'Hi {{user.name}}, your code is {{code}}. It expires in {{minutes}} minutes.',
html: '<p>Hi {{user.name}},</p><p>Your code is <b>{{code}}</b>.</p>',
},
},
},
sms: {
sender,
templates: { passwordReset: '{{appName}}: {{code}} is your reset code ({{minutes}} min).' },
},
import { render } from '@react-email/render';
import ResetEmail from './emails/ResetEmail.js';
mail: {
transporter,
from: 'Acme <no-reply@acme.com>',
templates: {
// Receives { code, minutes, appName, user, purpose }; may be async.
passwordReset: async ({ code, minutes, user }) => ({
subject: `Your code: ${code}`,
html: await render(ResetEmail({ name: user.name, code, minutes })),
}),
},
},
sms: {
sender,
templates: { passwordReset: ({ code }) => `Your Acme code is ${code}` },
},
Placeholder values are HTML-escaped inside html, so a user who names themselves
<script> cannot inject markup into your email. A mail template must produce a
subject and at least one of text or html. The defaults are exported
as DEFAULT_TEMPLATES if you want to start from them.
SMTP transporter#
The default mail sender uses a transporter — a nodemailer transporter, or any object
with sendMail({ from, to, subject, text, html }). Pass one in, or let the library build it
from the environment.
import nodemailer from 'nodemailer';
createAuthRouter({
model: User,
token: { secret: process.env.JWT_SECRET },
appName: 'Acme',
mail: {
transporter: nodemailer.createTransport({
host: 'smtp.example.com', port: 587,
auth: { user: process.env.SMTP_USER, pass: process.env.SMTP_PASS },
}),
from: 'Acme <no-reply@acme.com>',
},
});
npm install nodemailer
# .env — SMTP_URL, or the parts
SMTP_URL=smtps://apikey:secret@smtp.example.com
# SMTP_HOST=smtp.example.com
# SMTP_PORT=587 # 465 implies SMTP_SECURE=true
# SMTP_SECURE=false
# SMTP_USER=apikey
# SMTP_PASS=secret
MAIL_FROM="Acme <no-reply@acme.com>"
APP_NAME=Acme
With those set and no mail option at all, email just works. nodemailer is loaded on
first send, so apps that never mail anything don't need it installed.
Checked at startup. A transporter without sendMail, or no
from address, stops the app from starting — instead of surfacing as a failed email on
the first real password reset.
Choosing email or SMS#
The client may send "channel": "email" or "sms"; without it, email is used when
configured, otherwise SMS.
- A channel you haven't configured is a 400 — decided before the account is looked up, so the answer is the same for every account.
- No address on file (a user without a phone asking for SMS) gets the same "sent" response as everyone else; the miss is logged, not revealed.
- The recipient is read from
emailandphone; change it withmail.toField/sms.toField.
Upgrading from otp.deliver? It still works and overrides everything on
this page. Move to mail / sms when convenient — you get templates and
startup checks for free.