controller-sets v3.3.0

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 configureWhat happens
mail.transporter or SMTP_*Codes go by email through nodemailer (or anything with sendMail).
mail.senderYour function sends the email — Resend, SES, Postmark, a queue.
sms.senderCodes can go by SMS. There is no default SMS provider.
NothingCodes are never generated, and a warning is logged.

Common options#

OptionWhat it does
mail.transporterA nodemailer transporter (or anything with sendMail).
mail.fromSender address. Or MAIL_FROM.
mail.senderYour own send function — Resend, SES, a queue. Replaces the transporter.
mail.templatesYour own subject, text and HTML.
sms.senderTurns on SMS codes — Twilio, Vonage, SNS…
sms.templatesYour 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 }),
  },
});
SenderReceives
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.

PlaceholderValue
{{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 email and phone; change it with mail.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.

Esc
↑ ↓ to move↵ to open