# Magic links and codes (/docs/authentication/passwordless)

> 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
session, 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 tenant.

## Setup [#setup]

      ### Configure the server [#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`.

    ```ts title="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 [#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.

    ```ts title="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 [#deliver-the-outbox-quickly]

    A link or code expires five minutes after it is created, and messages leave through the
    outbox 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](/docs/operations/jobs).

    ```bash
    npx better-iam outbox --config better-iam.config.mjs
    ```
  
      ### Migrate the database [#migrate-the-database]

    Pending links and codes are stored in the challenge store that `migrate` creates with the rest of the schema.

    ```bash
    npx better-iam migrate --config better-iam.config.mjs
    ```
  
      ### Create the client [#create-the-client]

    ```ts title="lib/iam-client.ts"
    import { createIamClient } from 'better-iam/client';
    import type { iam } from './iam';

    export const client = createIamClient<typeof iam>();
    ```
  
## Usage [#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 [#send-a-magic-link]

  **Server:**

    ```ts title="app/api/magic-link.ts"
    import { iam } from './iam';

    await iam.api.auth.startPasswordless({
      tenantId,
      destination: 'alice@example.com',
      channel: 'email',
      kind: 'magic-link',
    });
    ```
  
  **Client:**

    ```ts title="login-with-link.ts"
    import { client } from './lib/iam-client';

    await client.auth.startPasswordless({
      tenantId,
      destination: 'alice@example.com',
      channel: 'email',
      kind: 'magic-link',
    });
    showCheckYourInbox();
    ```
  
**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 [#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.

  **Server:**

    ```ts title="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);
    }
    ```
  
  **Client:**

    ```ts title="magic.ts"
    import { client } from './lib/iam-client';

    const params = new URLSearchParams(location.search);
    const result = await client.auth.finishPasswordless({
      tenantId: params.get('tenant')!,
      destination: params.get('to')!,
      token: params.get('token')!,
    });
    if ('mfaRequired' in result) showSecondFactor(result);
    else location.assign('/'); // the HTTP handler set the better-iam.session cookie
    ```
  
**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 [#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.

  **Server:**

    ```ts title="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',
    });
    ```
  
  **Client:**

    ```ts title="email-code.ts"
    import { client } from './lib/iam-client';

    await client.auth.startPasswordless({
      tenantId,
      destination: 'alice@example.com',
      channel: 'email',
      kind: 'code',
    });

    const result = await client.auth.finishPasswordless({
      tenantId,
      destination: 'alice@example.com',
      token: codeInput.value, // the six-digit code
    });
    ```
  
**Route:** `POST /auth/startPasswordless`, `POST /auth/finishPasswordless`

### Sign in with an SMS code [#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.

  **Server:**

    ```ts title="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',
    });
    ```
  
  **Client:**

    ```ts title="sms-code.ts"
    import { client } from './lib/iam-client';

    await client.auth.startPasswordless({ tenantId, destination: '+15551234567', channel: 'sms', kind: 'code' });
    const result = await client.auth.finishPasswordless({ tenantId, destination: '+15551234567', token: code });
    ```
  
**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 [#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.

  **Server:**

    ```ts title="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' },
    );
    ```
  
  **Client:**

    ```ts title="phone.ts"
    import { client } from './lib/iam-client';

    await client.auth.startPhoneVerification({ phone: '+15551234567' });
    await client.auth.confirmPhoneVerification({ 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](/docs/authentication/recovery#phone-numbers).

### Handle the MFA challenge [#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](/docs/authentication/mfa#completing-a-challenge). A `deviceToken` from "remember this device"
satisfies the requirement, exactly as with `signIn`.

## Behavior [#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](/docs/authentication/email-and-password#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](/docs/authentication/http#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 [#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](/docs/federation/scim) or [federated sign-in](/docs/federation/oauth-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 [#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 [#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 [#errors]

| Code                                                              | When                                                                                                         |
| ----------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------ |
| [`INVALID_CHALLENGE`](/docs/reference/errors#invalid_challenge)   | The token is wrong, used, expired, issued for another destination, or the address changed since it was sent. |
| [`INVALID_INPUT`](/docs/reference/errors#invalid_input)           | An unknown `channel` or `kind`, a malformed email or phone number, or `kind: 'magic-link'` over SMS.         |
| [`FEATURE_DISABLED`](/docs/reference/errors#feature_disabled)     | The channel is not enabled in the deployment.                                                                |
| [`METHOD_NOT_ALLOWED`](/docs/reference/errors#method_not_allowed) | The tenant's `allowedMethods` excludes the channel.                                                          |
| [`RATE_LIMITED`](/docs/reference/errors#rate_limited)             | Too many attempts for this destination; the error carries `retryAfterMs`.                                    |
| [`PHONE_EXISTS`](/docs/reference/errors#phone_exists)             | Phone verification of a number another identity in the tenant already verified.                              |
| [`TENANT_UNAVAILABLE`](/docs/reference/errors#tenant_unavailable) | The tenant or one of its ancestors is suspended or missing.                                                  |
| [`INVALID_CONFIG`](/docs/reference/errors#invalid_config)         | At startup: a channel is enabled without its delivery callback.                                              |

## Next steps [#next-steps]

  - [Email and password](/docs/authentication/email-and-password): Password sign-in, verification, and reset.

  - [Passkeys](/docs/authentication/passkeys): Phishing-resistant sign-in that also satisfies MFA.

  - [Multi-factor authentication](/docs/authentication/mfa): Answer the MFA challenge a passwordless sign-in can return.

  - [Verification and recovery](/docs/authentication/recovery): Phone verification, email changes, and lockouts.
