Magic links and codes
Passwordless sign-in in Better IAM with emailed magic links, emailed six-digit codes, and SMS codes, using auth.startPasswordless and auth.finishPasswordless.
Passwordless sign-in proves that a person controls an email address or phone number by sending a single-use secret there. People never choose, reuse, or forget a password. Better IAM offers three variants of one two-step flow:
| Variant | channel | kind | Session method | Needs |
|---|---|---|---|---|
| Magic link | email | magic-link | passwordless-email | passwordlessEmail: true, sendEmail |
| Emailed code | email | code | passwordless-email | passwordlessEmail: true, sendEmail |
| SMS code | sms | code | passwordless-sms | passwordlessSms: true, sendSms, a verified phone number |
auth.startPasswordless sends the link or a six-digit code, and auth.finishPasswordless redeems it and returns a
, or an MFA challenge when a second factor is required. Links and codes are
single-use and expire after five minutes.
Use magic links when people sign in on the device that reads their mail, and codes when they might read the message on another device (a phone) than the one they sign in on. Passwordless sign-in does not create accounts: it signs in people who already exist in the .
Setup
Configure the server
Both channels are off by default. Enable the ones you need and give each a delivery callback. Construction fails
with INVALID_CONFIG when passwordlessEmail is on without sendEmail, or passwordlessSms without sendSms.
import { betterIam } from 'better-iam';
import { sqliteAdapter } from 'better-iam/adapter-sqlite';
import { sendMail, sendText } from './delivery';
export const iam = betterIam({
database: sqliteAdapter({ filename: './iam.db' }),
secret: process.env.BETTER_IAM_SECRET!,
baseURL: process.env.BETTER_IAM_BASE_URL!,
authentication: {
passwordlessEmail: true, // magic links and emailed codes
passwordlessSms: true, // SMS codes
sendEmail: async (message) => sendMail(message),
sendSms: async (message) => sendText(message),
// emailPassword: false, // optional: passwordless-only, no password sign-in at all
},
});Render the messages
The outbox queues a magic-link message for links and a code message for codes, each with the secret in
payload.token. renderDeliveryMessage from better-iam/auth/templates renders both. Your magicLink builder
decides the URL of the page that finishes the sign-in, and receives the destination the page must send back.
import type { DeliveryMessage } from 'better-iam/auth';
import { renderDeliveryMessage } from 'better-iam/auth/templates';
import { smsProvider, transport } from './providers';
const origin = 'https://app.example.com';
export async function sendMail(message: DeliveryMessage): Promise<void> {
const rendered = renderDeliveryMessage(message, {
appName: 'Acme Cloud',
links: {
magicLink: ({ tenantId, token, destination }) =>
`${origin}/magic?tenant=${tenantId}&token=${token}&to=${encodeURIComponent(destination)}`,
},
});
if (!rendered) throw new Error(`No renderer for ${message.template}`);
await transport.send({ id: message.id, to: message.to, ...rendered });
}
export async function sendText(message: DeliveryMessage): Promise<void> {
// renderDeliveryMessage knows `code` but not `phone-verify`, so render that one yourself.
const body =
message.template === 'phone-verify'
? `Your Acme Cloud verification code is ${message.payload.token}`
: renderDeliveryMessage(message, { appName: 'Acme Cloud' })?.text;
if (!body) throw new Error(`No renderer for ${message.template}`);
await smsProvider.send({ id: message.id, to: message.to, body });
}Deliver the outbox quickly
A link or code expires five minutes after it is created, and messages leave through the
worker after commit. Run the worker every minute (better-iam outbox), or call
iam.auth.dispatchOutbox() right after startPasswordless in the same process, so the message arrives while it is
still valid. See scheduled jobs.
npx better-iam outbox --config better-iam.config.mjsMigrate the database
Pending links and codes are stored in the challenge store that migrate creates with the rest of the schema.
npx better-iam migrate --config better-iam.config.mjsCreate the client
import { createIamClient } from 'better-iam/client';
import type { iam } from './iam';
export const client = createIamClient<typeof iam>();Usage
Both steps are public: they take only an input. Server code calls iam.api.auth.startPasswordless and
iam.api.auth.finishPasswordless; browser code calls the same methods on the client, and the HTTP handler sets the
session cookie when a session is issued. Routes are relative to the handler's basePath (/api/iam by default).
Send a magic link
import { iam } from './iam';
await iam.api.auth.startPasswordless({
tenantId,
destination: 'alice@example.com',
channel: 'email',
kind: 'magic-link',
});Route: POST /auth/startPasswordless
The answer is always { success: true }, whether or not the address belongs to anyone, so it cannot be used to
discover accounts. A message is sent only when an active person in that tenant has that email address.
Finish a magic-link sign-in
On the page the link opens, read the token and the destination from the URL your magicLink builder produced and
redeem them.
import { iam } from './iam';
export async function GET(request: Request) {
const url = new URL(request.url);
// withClient records the client for network rules and the sign-in record. Read the IP the way your proxy sets it.
const client = {
ip: request.headers.get('x-real-ip') ?? undefined,
userAgent: request.headers.get('user-agent') ?? undefined,
};
const result = await iam.auth.withClient(client, () =>
iam.api.auth.finishPasswordless({
tenantId: url.searchParams.get('tenant')!,
destination: url.searchParams.get('to')!,
token: url.searchParams.get('token')!,
}),
);
if ('mfaRequired' in result) return Response.redirect(new URL('/second-factor', url), 303);
// result.token is the session token; set your own cookie when you do not use the HTTP handler.
return Response.redirect(new URL('/', url), 303);
}Route: POST /auth/finishPasswordless
Finishing in the browser with the client is the simpler path: the HTTP handler records the client, sets the session
cookie, and adds a remembered-device token from the better-iam.device cookie for you.
Sign in with an emailed code
A code works the same way with kind: 'code'. The person types the six digits on the page where they started.
import { iam } from './iam';
await iam.api.auth.startPasswordless({
tenantId,
destination: 'alice@example.com',
channel: 'email',
kind: 'code',
});
// Later, with the code the person typed:
const result = await iam.api.auth.finishPasswordless({
tenantId,
destination: 'alice@example.com',
token: '482913',
});Route: POST /auth/startPasswordless, POST /auth/finishPasswordless
Sign in with an SMS code
SMS supports codes only (INVALID_INPUT for an SMS magic link). The destination is an E.164 number, and a message
is sent only to a number the person has already verified from their account.
import { iam } from './iam';
await iam.api.auth.startPasswordless({
tenantId,
destination: '+15551234567',
channel: 'sms',
kind: 'code',
});
const result = await iam.api.auth.finishPasswordless({
tenantId,
destination: '+15551234567',
token: '482913',
});Route: POST /auth/startPasswordless, POST /auth/finishPasswordless
finishPasswordless reads a destination that starts with + as a phone number and anything else as an email
address, so pass exactly the destination the message went to.
Verify a phone number first
People add a phone number from a signed-in, recently authenticated session. auth.startPhoneVerification texts a
six-digit code with the phone-verify template, valid for five minutes, and auth.confirmPhoneVerification checks it
and marks the number verified.
import { iam } from './iam';
await iam.api.auth.startPhoneVerification({ headers: request.headers }, { phone: '+15551234567' });
await iam.api.auth.confirmPhoneVerification(
{ headers: request.headers },
{ phone: '+15551234567', code: '482913' },
);Route: POST /auth/startPhoneVerification, POST /auth/confirmPhoneVerification
A number already verified by another identity in the tenant fails with PHONE_EXISTS. Verification is the only way
a phone number becomes usable for SMS sign-in. See phone numbers.
Handle the MFA challenge
finishPasswordless returns the same SignInResult as a password sign-in. When the person must complete MFA, the
result is { mfaRequired: true, challenge, enrollmentRequired, emailCodeAvailable?, passkeyAvailable? } and you
continue with auth.verifyMfa or one of the other challenge answers; see
completing a challenge. A deviceToken from "remember this device"
satisfies the requirement, exactly as with signIn.
Behavior
- One live secret per destination. Starting again deletes the earlier link or code for that destination, so only the newest one works.
- Single use, five minutes. A token is consumed when it is redeemed. A wrong, used, or expired token fails with
INVALID_CHALLENGE. - Bound to the address. Finishing fails with
INVALID_CHALLENGEwhen the person's email or phone changed after the message was sent, or when the token was issued for another destination. - Email proves the address. Finishing an emailed flow marks the email address verified.
- No account linking by address. Passwordless sign-in signs in the identity that owns the address in that tenant. It never creates an account and never links accounts because their addresses match.
- Address owners win. When an account was self-registered with a password and its address was never verified,
the first emailed sign-in by the address owner removes that password and ends every session and remembered device
it produced (audited as
auth:password:clear). See self-registration and address ownership. - Rate limits. Both steps count against the sensitive tier (5 attempts per 15 minutes by default), per destination. See rate limits.
- Tenant policy. The tenant's
allowedMethodsmust includepasswordless-emailorpasswordless-sms(METHOD_NOT_ALLOWED). - Audit.
startPasswordlesswrites no event; a finished sign-in is audited asauth:session:createwith the method.
Accounts without a password
Because passwordless sign-in never creates accounts, people must exist first. Common ways to create them:
identities.createoridentities.createManywithout apassword(an administrator, or your own sign-up endpoint running with an administrator credential).- SCIM or federated sign-in, which provision people from an identity provider.
auth.signUp always requires a password, so a self-service passwordless sign-up has to be built on one of these.
Options
Deployment options live under authentication in the server options.
| Option | Type | Default | Description |
|---|---|---|---|
passwordlessEmail | boolean | false | Enables magic links and emailed codes. Needs sendEmail. |
passwordlessSms | boolean | false | Enables SMS codes. Needs sendSms. |
sendEmail | (message: DeliveryMessage) => Promise<void> | none | Delivers magic-link and code emails. Throw to have the outbox retry; deduplicate by message.id. |
sendSms | (message: DeliveryMessage) => Promise<void> | none | Delivers code and phone-verify text messages. |
maxDeliveryAttempts | number | 25 | Failed delivery attempts before the outbox abandons a message. |
rateLimits.sensitiveAttempts | number | 5 | Attempts per window for both passwordless steps, per destination. |
rateLimits.windowMs | number | 900000 (15 minutes) | The rate-limit window. |
startPasswordless takes:
| Field | Type | Description |
|---|---|---|
tenantId | string | The tenant to sign in to. Never inferred from the address. |
destination | string | An email address, or an E.164 phone number such as +15551234567. |
channel | 'email' | 'sms' | Where to send the secret. |
kind | 'magic-link' | 'code' | A link token or a six-digit code. SMS requires code. |
finishPasswordless takes tenantId, the same destination, token (the link token or the code), and an optional
deviceToken.
Schema
| Collection | Key fields | Purpose |
|---|---|---|
authChallenges | purpose: 'passwordless', tokenHash, identityId, expiresAt, payload.destination, payload.channel | The pending link or code, stored as an HMAC-SHA-256 digest keyed by the deployment secret, one per destination. |
outbox | template (magic-link, code, phone-verify), to, sealed payload | Queued messages until the outbox worker delivers them. |
identities | email, emailVerified, phone, phoneVerified | The address a sign-in matches. SMS matches only phoneVerified: true. |
sessions | kind: 'user', method (passwordless-email or passwordless-sms) | The session a finished sign-in issues. |
Errors
| Code | When |
|---|---|
INVALID_CHALLENGE | The token is wrong, used, expired, issued for another destination, or the address changed since it was sent. |
INVALID_INPUT | An unknown channel or kind, a malformed email or phone number, or kind: 'magic-link' over SMS. |
FEATURE_DISABLED | The channel is not enabled in the deployment. |
METHOD_NOT_ALLOWED | The tenant's allowedMethods excludes the channel. |
RATE_LIMITED | Too many attempts for this destination; the error carries retryAfterMs. |
PHONE_EXISTS | Phone verification of a number another identity in the tenant already verified. |
TENANT_UNAVAILABLE | The tenant or one of its ancestors is suspended or missing. |
INVALID_CONFIG | At startup: a channel is enabled without its delivery callback. |
Next steps
Better IAM is created by Sean Filimon
Last updated
Email and password
Password sign-up and sign-in, email verification, password reset and change, deployment and organization password rules, and Argon2id hashing in Better IAM.
Passkeys
Register passkeys, sign in with them (including discoverable autofill sign-in), use them as a second factor, and let people name and manage them.