BetterIAM

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:

VariantchannelkindSession methodNeeds
Magic linkemailmagic-linkpasswordless-emailpasswordlessEmail: true, sendEmail
Emailed codeemailcodepasswordless-emailpasswordlessEmail: true, sendEmail
SMS codesmscodepasswordless-smspasswordlessSms: 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.

iam.ts
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.

delivery.ts
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.mjs

Migrate 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.mjs

Create the client

lib/iam-client.ts
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).

app/api/magic-link.ts
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.

On the page the link opens, read the token and the destination from the URL your magicLink builder produced and redeem them.

app/magic/route.ts
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.

app/api/email-code.ts
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.

app/api/sms-code.ts
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.

app/api/phone.ts
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_CHALLENGE when 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 allowedMethods must include passwordless-email or passwordless-sms (METHOD_NOT_ALLOWED).
  • Audit. startPasswordless writes no event; a finished sign-in is audited as auth:session:create with the method.

Accounts without a password

Because passwordless sign-in never creates accounts, people must exist first. Common ways to create them:

  • identities.create or identities.createMany without a password (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.

OptionTypeDefaultDescription
passwordlessEmailbooleanfalseEnables magic links and emailed codes. Needs sendEmail.
passwordlessSmsbooleanfalseEnables SMS codes. Needs sendSms.
sendEmail(message: DeliveryMessage) => Promise<void>noneDelivers magic-link and code emails. Throw to have the outbox retry; deduplicate by message.id.
sendSms(message: DeliveryMessage) => Promise<void>noneDelivers code and phone-verify text messages.
maxDeliveryAttemptsnumber25Failed delivery attempts before the outbox abandons a message.
rateLimits.sensitiveAttemptsnumber5Attempts per window for both passwordless steps, per destination.
rateLimits.windowMsnumber900000 (15 minutes)The rate-limit window.

startPasswordless takes:

FieldTypeDescription
tenantIdstringThe tenant to sign in to. Never inferred from the address.
destinationstringAn 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

CollectionKey fieldsPurpose
authChallengespurpose: 'passwordless', tokenHash, identityId, expiresAt, payload.destination, payload.channelThe pending link or code, stored as an HMAC-SHA-256 digest keyed by the deployment secret, one per destination.
outboxtemplate (magic-link, code, phone-verify), to, sealed payloadQueued messages until the outbox worker delivers them.
identitiesemail, emailVerified, phone, phoneVerifiedThe address a sign-in matches. SMS matches only phoneVerified: true.
sessionskind: 'user', method (passwordless-email or passwordless-sms)The session a finished sign-in issues.

Errors

CodeWhen
INVALID_CHALLENGEThe token is wrong, used, expired, issued for another destination, or the address changed since it was sent.
INVALID_INPUTAn unknown channel or kind, a malformed email or phone number, or kind: 'magic-link' over SMS.
FEATURE_DISABLEDThe channel is not enabled in the deployment.
METHOD_NOT_ALLOWEDThe tenant's allowedMethods excludes the channel.
RATE_LIMITEDToo many attempts for this destination; the error carries retryAfterMs.
PHONE_EXISTSPhone verification of a number another identity in the tenant already verified.
TENANT_UNAVAILABLEThe tenant or one of its ancestors is suspended or missing.
INVALID_CONFIGAt startup: a channel is enabled without its delivery callback.

Next steps

Was this page helpful?

Better IAM is created by Sean Filimon

Last updated

On this page