---
title: "Sign-in and sessions"
description: "Sign people in with passkeys, WhatsApp codes, sign-in links, social login or company SSO, and keep sessions short-lived, device-bound and audited."
section: "interfaces"
status: "Building for 0.3 · ships as a stable API"
spec: "auth"
canonical: "https://mainsdk.dev/docs/interfaces/auth"
updated: "2026-10-28"
---

# Sign-in and sessions

> Sign people in with passkeys, WhatsApp codes, sign-in links, social login or company SSO, and keep sessions short-lived, device-bound and audited.

Status: Building for 0.3 · ships as a stable API · Canonical: https://mainsdk.dev/docs/interfaces/auth · Updated: 2026-10-28

`auth` signs people into your app and tells your code who they are: passkeys by default, WhatsApp codes, sign-in links, social login, SSO, sessions and organisations. Users and sessions live in your own `kv` driver.

Reach for it on the first route that needs to know the user. You also get device-bound sessions, risk-based step-up, teams, invites, SCIM provisioning and enterprise sign-in for your own customers (OIDC and SAML). MainSDK never becomes an identity provider you depend on.

## At a glance

- Status: Building for 0.3 · ships as a stable API
- Phase: 1 (Core, public beta)
- Milestone: 0.3 Every interface
- Plan: free in your app (Apache-2.0)
- Spec: https://github.com/hubxero/mainsdk/blob/develop/docs/specs/auth.md
- Category: Identity and access

Calls:

- `auth.session(req)`
- `auth.require(req, { role, aal, fresh })`
- `auth.passkeys.register()`
- `auth.passkeys.signIn()`
- `auth.whatsapp.send()`
- `auth.sso.enable()`
- `auth.scim.enable()`
- `auth.challenge.start({ method })`
- `auth.challenge.verify({ message, signature })`
- `auth.device.start({ scope })`
- `auth.devices(user)`
- `auth.devices.revoke(user, deviceId)`
- `auth.orgs.create({ name, slug?, region })`
- `auth.orgs.list()`
- `auth.orgs.switch(req, tenantId)`
- `auth.members.list({ cursor?, limit? })`
- `auth.members.setRoles(userId, roles)`
- `auth.members.remove(userId)`
- `auth.teams.create(name)`
- `auth.teams.list()`
- `auth.teams.add(teamId, userId)`
- `auth.teams.remove(teamId, userId)`
- `auth.invites.create({ email, roles, teams?, expiresIn? })`
- `auth.invites.accept(req, token)`
- `auth.invites.revoke(inviteId)`
- `auth.domains.add(domain)`
- `auth.domains.verify(domain)`
- `auth.domains.autoJoin(domain, { role } | false)`
- `auth.sso.require(tenantId, { except? })`
- `new Auth(opts)`
- `new StepUpRequired(o)`
- `assessRisk(input, cfg)`
- `createAuth(opts)`
- `isPhishingResistantMethod(method)`
- `maskPhone(e164)`
- `normalizePhone(input)`
- `auth.handle(req, fn)`
- `auth.passwords.register(req, { email, password, name? })`
- `auth.passwords.signIn(req, { email, password })`
- `auth.link.issue({ email } | { phone }, { bind?, req? })`
- `auth.link.verify(req, token)`
- `auth.oauth.start(provider, { redirectUri, returnTo? })`
- `auth.oauth.callback(req)`
- `auth.stepUp.start({ pending, challenge, method })`
- `auth.stepUp.complete(req, { pending, challenge, method, ... })`
- `auth.recover(user, { via, req? })`
- `auth.recover.confirm(challengeId, code)`
- `auth.recover.cancel(session)`
- `auth.recover.complete(req, recovery)`
- `auth.recover.withCode(req, user, code)`
- `auth.recover.withPasskey(req, user, assertion)`
- `auth.recover.generateCodes(session)`
- `auth.recover.codesLeft(user)`
- `auth.sso.get()`
- `auth.sso.signIn({ email } | { org }, { redirectUri, returnTo? })`
- `auth.sso.callback(req)`
- `auth.sso.saml.metadata(org, { acsUrl })`
- `auth.sso.saml.acs(req)`
- `auth.risk(req, { userId })`
- `auth.signOut(req)`
- `auth.signOutEverywhere(req)`
- `auth.onRisk(fn)`
- `auth.raw()`

In main.yaml:

```yaml
auth:
  driver: github                       # owner default; the auth spec's example uses clerk
  methods: [passkeys, whatsapp, sso]
  signup: open                         # open | closed
  oauth:
    google: { preset: oidc, issuer: https://accounts.google.com, client-id: "...", key: secret:GOOGLE_SECRET }
  whatsapp: { driver: whatsapp-cloud }
  sso: { protocols: [saml, oidc], scim: true }
  session: { ttl: 15m, refresh: 7d, bind: dbsc }
  orgs:
    invites: { expires: 7d }
    domains: { auto-join: true }
  routes: { base: /_mainsdk/auth }     # where every adapter mounts the browser sign-in routes
```

Guarantees, whatever the driver:

- Short-lived, HttpOnly, Secure, SameSite sessions with rotating refresh tokens
- Passkeys by default, phishing-resistant
- Fails closed when the provider cannot be reached
- WhatsApp codes single use, expiring and rate limited per phone and per IP
- Every recovery audited, never weaker than sign-in


## Drivers

Pick one in main.yaml with `auth.driver`.

| Driver | Id in main.yaml | Default | Note |
| --- | --- | --- | --- |
| GitHub | `github` | yes |  |
| Clerk | `clerk` |  |  |
| Auth0 | `auth0` |  |  |
| Better Auth | `better-auth` |  |  |
| WorkOS | `workos` |  |  |
| Supabase Auth | `supabase` |  |  |
| Local (development only) | `local` |  |  |

## Codes

| Code | Where | Meaning |
| --- | --- | --- |
| `MAIN-AUTH-0053` | check | `auth.risk.step-up` names a method that isn't on, or only methods weaker than the ones it steps up from |
| `MAIN-AUTH-0055` | check | The identity-provider driver named by `auth.driver` does not offer a method (its capability profile says `false`). High, and it fails the check, when the app uses the method; a warning otherwise |
| `MAIN-AUTH-0001` | runtime | Not signed in (401 from `auth.require()`) |
| `MAIN-AUTH-0006` | runtime | WhatsApp code reused, expired or tried too often; the challenge is closed |
| `MAIN-AUTH-0007` | runtime | OIDC SSO callback with a mismatched `state` or `nonce` |
| `MAIN-AUTH-0008` | runtime | SAML assertion unsigned, for the wrong audience, expired or replayed |
| `MAIN-AUTH-0041` | runtime | Invite used, expired, revoked, or accepted by a different email (410); a mismatch leaves the invite valid |
| `MAIN-AUTH-0042` | runtime | Domain not verified, already verified by another tenant, or a public email domain |
| `MAIN-AUTH-0043` | runtime | The change would leave the tenant with no owner (409) |
| `MAIN-AUTH-0044` | runtime | The tenant requires its SSO; this session came from another method (403) |
| `MAIN-AUTH-0045` | runtime | Invites, teams and domains are not available in a personal tenant (409) |
| `MAIN-AUTH-0002` | runtime |  |
| `MAIN-AUTH-0003` | runtime |  |
| `MAIN-AUTH-0004` | runtime |  |
| `MAIN-AUTH-0005` | runtime |  |
| `MAIN-AUTH-0010` | runtime |  |
| `MAIN-AUTH-0011` | runtime |  |
| `MAIN-AUTH-0012` | runtime | Check (high): an admin role in `auth.admin.roles` can sign in with a method phishing can defeat while `auth.admin.require` isn't `phishing-resistant`. Runtime: `auth.raw()` was called (logged). Fix: set `auth.admin.require: phishing-resistant`, or remove the weak methods from `auth.methods` |
| `MAIN-AUTH-0013` | runtime |  |
| `MAIN-AUTH-0014` | runtime |  |
| `MAIN-AUTH-0015` | runtime |  |
| `MAIN-AUTH-0016` | runtime |  |
| `MAIN-AUTH-0017` | runtime |  |
| `MAIN-AUTH-0018` | runtime |  |
| `MAIN-AUTH-0019` | runtime |  |
| `MAIN-AUTH-0020` | runtime |  |
| `MAIN-AUTH-0021` | runtime |  |
| `MAIN-AUTH-0022` | runtime |  |
| `MAIN-AUTH-0023` | runtime |  |
| `MAIN-AUTH-0024` | runtime |  |
| `MAIN-AUTH-0025` | runtime |  |
| `MAIN-AUTH-0026` | runtime |  |
| `MAIN-AUTH-0027` | runtime |  |
| `MAIN-AUTH-0050` | runtime |  |
| `MAIN-AUTH-0051` | runtime |  |
| `MAIN-AUTH-0052` | runtime |  |
| `MAIN-AUTH-0054` | runtime |  |
| `MAIN-AUTH-0056` | runtime |  |
| `MAIN-AUTH-0060` | runtime |  |
| `MAIN-AUTH-0061` | runtime |  |
| `MAIN-AUTH-0080` | runtime |  |
| `MAIN-AUTH-0081` | runtime | The person cancelled the passkey prompt, or the browser refused it (`NotAllowedError` or `AbortError`); not retryable without a new gesture |
| `MAIN-AUTH-0082` | runtime | The auth routes answered 404 or HTML: the adapter isn't mounted or `base` is wrong |
| `MAIN-AUTH-0083` | runtime | This browser has no WebAuthn, or no conditional mediation when `mediation: "conditional"` was asked for |
| `MAIN-AUTH-0084` | runtime |  |
| `MAIN-AUTH-0040` | runtime | A sign-in challenge was verified a second time; a challenge works once |
| `MAIN-AUTH-0046` | runtime | Sign-in link used, expired, unknown, or opened in a browser other than the one that asked (401; the challenge is closed) |
| `MAIN-AUTH-0090` | runtime | A password is stored with a bare hash instead of passkeys or the auth interface |
| `MAIN-AUTH-0091` | runtime | The sign-in uses the OAuth implicit flow (`response_type=token`) |
| `MAIN-AUTH-0100` | runtime |  |
| `MAIN-AUTH-0101` | runtime | `assetlinks.json` or `apple-app-site-association` does not match the signing identity declared in `targets.mobile.signing`, so app links and passkeys would not bind to the app |
| `MAIN-AUTH-0102` | runtime |  |
| `MAIN-AUTH-0103` | runtime |  |
| `MAIN-AUTH-0104` | runtime |  |
| `MAIN-AUTH-0105` | runtime |  |
| `MAIN-AUTH-0106` | runtime |  |
| `MAIN-AUTH-0200` | runtime |  |
| `MAIN-AUTH-0201` | runtime |  |
| `MAIN-AUTH-0202` | runtime |  |
| `MAIN-AUTH-0203` | runtime |  |
| `MAIN-AUTH-0204` | runtime |  |
| `MAIN-AUTH-0205` | runtime |  |
| `MAIN-AUTH-0206` | runtime |  |

## Features

| Feature | Phase | Milestone | Spec ticket |
| --- | --- | --- | --- |
| Wallet sign-in by challenge signature | 2 | 0.7 | `web3-ready` |
| Sessions and require | 1 | 0.3 | `auth#session-require` |
| Web passkeys and browser QR | 1 | 0.3 | `auth#passkeys-web` |
| CLI device sign-in | 1 | 0.3 | `auth#device-flow` |
| WhatsApp one-time codes | 1 | 0.3 | `auth#whatsapp-codes` |
| OIDC single sign-on | 1 | 0.3 | `auth#sso-oidc` |
| SAML single sign-on | 1 | 0.3 | `auth#sso-saml` |
| SAML sign-in request in the redirect binding | 1 | 0.3 | `auth#sso-saml-request` |
| SCIM provisioning | 1 | 0.3 | `auth#scim` |
| Native passkeys and app-to-desktop QR | 3 | 1.1 | `mobile-auth` |
| Organisations | 1 | 0.3 | `auth#orgs-members` |
| Email-bound | 1 | 0.3 | `auth#invites` |
| Teams usable as access subjects | 1 | 0.3 | `auth#teams` |
| Verified email domains and auto-join | 1 | 0.3 | `auth#domains` |
| Require SSO per organisation | 1 | 0.3 | `auth#sso-per-tenant` |
| Sign-in routes mounted by every adapter | 1 | 0.4 | `auth-client#routes` |
| Browser client for passkeys with autofill | 1 | 0.4 | `auth-client#client-passkeys` |
| Sign-in links and WhatsApp codes from the page | 1 | 0.4 | `auth-client#link-whatsapp` |
| Step-up | 1 | 0.4 | `auth-client#step-up` |

## MainSDK Cloud

Everything on this page is free in your app.


## Quick start

Turn on the methods you want in `main.yaml`, then guard a route with `auth.require()`.

```yaml title="main.yaml"
auth:
  key: secret:AUTH_KEY            # signs sessions; required in production
  methods: [passkeys, whatsapp]
  passkeys:
    origins: ["https://app.example.com"]
```

TypeScript:

```ts
import { auth, problem } from "mainsdk";

export async function GET(req: Request) {
  try {
    const session = await auth.require(req);   // throws MAIN-AUTH-0001 (401) if not signed in
    return Response.json({ user: session.user, aal: session.aal });
  } catch (err) {
    return problem(err);                       // RFC 9457 body with code and fix
  }
}
```

Rust:

```rust
use mainsdk::prelude::*;
let app = App::test();
let token = app.auth().start_session("tnt_a", "u1").await?;
let session = app.auth().require(Some(&token)).await?;
assert_eq!(session.user, "u1");
```


Without a session cookie the route answers 401 with code `MAIN-AUTH-0001` and a `fix`. With a valid session it answers 200. Where signing in is optional, call `auth.session(req)` instead: it returns the `Session` or `null`. A `Session` has `id`, `user` (the user id), `tenant`, `aal`, `amr`, `authTime`, `issuedAt`, `expiresAt`, `deviceId` and `family`; the tokens behind it never appear.

> **Note:** The Clerk and Better Auth provider drivers (`clerkDriver`, `betterAuthDriver`) ship with a capability profile and the auth conformance suite (`runAuthDriverConformance` in `mainsdk/testing`). Set `auth.driver: clerk` or `better-auth` and `mainsdk check` names each method the driver does not offer as `MAIN-AUTH-0055`: Clerk has no `startSso` or `provisionScim`, Better Auth has no `provisionScim`. A gap fails the check only when the app uses the method. Auth0, WorkOS, Supabase Auth and GitHub are planned. The built-in driver still runs every flow on this page and keeps users in your `kv` driver.

## Browser sign-in

Every [adapter](https://mainsdk.dev/docs/interfaces/adapters) mounts the sign-in routes under `auth.routes.base` (default `/_mainsdk/auth`), and `mainsdk/auth/client` is a dependency-free browser client for them: `auth.session.get()` and `watch()`, `auth.passkeys.signIn()` and `register()` (with autofill), `auth.link.send()` and `complete()`, `auth.whatsapp.send()` and `verify()`, `auth.stepUp.send()` and `verify()`, `auth.orgs.switch()` and `auth.signOut()`. Writes need the session's CSRF value, which the client sends for you. See [Add sign-in to a page](https://mainsdk.dev/docs/guides/sign-in-page).

## Configuration

Every key below lives under `auth:`.

```yaml title="main.yaml"
auth:
  key: secret:AUTH_KEY                 # master secret; previous-keys: [secret:OLD_KEY] rotates it
  methods: [passkeys, password, whatsapp, oauth, sso, link]
  signup: open                         # open | closed
  passkeys:
    origins: ["https://app.example.com"]
    user-verification: required        # required | preferred
  whatsapp:
    driver: whatsapp-cloud             # whatsapp-cloud | bird | msg91
    ttl: 5m
    max-tries: 5
    limits: { per-phone: 3, per-ip: 10, window: 10m }
  oauth:
    google: { preset: oidc, issuer: https://accounts.google.com, client-id: "...", key: secret:GOOGLE_SECRET }
  sso: { default-role: member, jit: true }
  session: { ttl: 15m, refresh: 7d }
  recovery: { wait: 24h }
  admin: { roles: [admin, support], require: phishing-resistant }
  orgs:
    invites: { expires: 7d }
    domains: { auto-join: true }
  risk:
    mode: enforce                      # observe | enforce
    step-up: [passkeys, whatsapp]
    breached: { driver: hibp }         # opt in; off unless set
```

| Key | Type | What it does |
| --- | --- | --- |
| `key` | secret reference | Signs sessions and seals email and phone at rest. Without it, production fails with `MAIN-AUTH-0011`; other environments use a random key that ends with the process |
| `methods` | list | `passkeys`, `password`, `whatsapp`, `oauth`, `sso`, `link`. Default `[passkeys]`; a method that is not listed refuses its calls |
| `signup` | string | `open` (default) or `closed`; closed refuses new accounts with `MAIN-AUTH-0017` |
| `passkeys.origins` | list | The https origins your app is served from. Passkeys and the device flow need one, or fail with `MAIN-AUTH-0024` |
| `passkeys.user-verification` | string | `required` (default) or `preferred` |
| `whatsapp.driver` | string | Sender for one-time codes, swappable with no code change |
| `whatsapp.ttl`, `max-tries` | duration, number | Code lifetime (5 minutes) and wrong tries before it closes (5) |
| `whatsapp.limits` | map | Sends per phone (3) and per IP (10) in a window (10 minutes) |
| `oauth.<id>` | map | A social provider: `preset`, `client-id`, `key` (a secret reference), and `issuer`, `tenant` or `scopes` where the preset needs them |
| `sso.default-role` | string | Role of a person provisioned by single sign-on. Default `member` |
| `sso.jit` | boolean | Create an account on first SSO sign-in. Default `true` |
| `session.ttl` | duration | Session cookie lifetime, 1 second to 15 minutes. Default 15 minutes |
| `session.refresh` | duration | Refresh token lifetime. Default 7 days; tokens rotate on every use |
| `recovery.wait` | duration | How long recovery waits before access. Default 24 hours |
| `admin.roles` | list | Roles that `auth.require()` only grants to a phishing-resistant sign-in (a passkey, or SSO that reports MFA) |
| `admin.require` | string | `phishing-resistant`; `mainsdk check` fails when an admin role can sign in without it |
| `orgs.invites.expires` | duration | How long an invite lasts. Default `7d` |
| `orgs.domains.auto-join` | boolean | Allows auto-join; each organisation still turns it on per verified domain |
| `risk.mode` | string | `observe` logs what a risk decision would do; `enforce` does it. A push may only move it to `enforce` |
| `risk.step-up` | list | Methods a step-up may use, in order |
| `risk.breached.driver` | string | Opt-in breached-password check by k-anonymity. Off unless set |

Organisations, members, invites and domains need a `tenancy:` section (see [tenants](https://mainsdk.dev/docs/interfaces/tenant)); without it `auth.orgs.create()` fails with `MAIN-TENANT-0001`.

### Overlays by market

Market overlays change sign-in for a market without touching code. India users get WhatsApp first:

```yaml title="main.in.yaml"
auth:
  methods: [whatsapp, passkeys]
```

See [main.yaml](https://mainsdk.dev/docs/concepts/main-yaml) for overlay order and merge rules.

## What you get

Sessions and guards, sign-in methods, recovery and organisations, all on one `auth` object. The sections below show each call in full.

| Call | What it does |
| --- | --- |
| `auth.session(req)` | The `Session` of the request, or `null` |
| `auth.require(req, { role, aal, fresh })` | The session, or a 401 or 403 with a fix |
| `auth.passkeys.register()` and `auth.passkeys.signIn()` | Phishing-resistant sign-in with passkeys |
| `auth.whatsapp.send()` | One-time WhatsApp codes, single use and rate limited |
| `auth.sso.enable()` and `auth.scim.enable()` | Company single sign-on and user provisioning |
| `auth.orgs.create()`, `auth.members.list()`, `auth.invites.create()` | Organisations, members, teams and invites per tenant |
| `auth.signOut(req)` and `auth.signOutEverywhere(req)` | End one session or every session of the person |

## Examples

Every call here ran against the in-memory fakes from [the testing kit](https://mainsdk.dev/docs/interfaces/testing). Calls that sign a person in return an `Issued` session: a `Session` plus `cookies`, the `Set-Cookie` lines you must send to the browser.

### Protect a route by role

`auth.require()` checks the role against the session's active organisation, the assurance level and how recently the person signed in. It fails closed with `MAIN-AUTH-0020` (503) when the store cannot be reached.

```ts title="app/api/admin/users/route.ts"
import { auth, problem } from "mainsdk";

export async function DELETE(req: Request) {
  try {
    const session = await auth.require(req, {
      role: "owner",
      aal: "aal2",        // phishing-resistant, or two factors
      fresh: "5m",        // signed in within the last five minutes
    });
    return Response.json({ by: session.user });
  } catch (err) {
    return problem(err);
  }
}
```

A missing session is 401 `MAIN-AUTH-0001`. A missing role is 403 `MAIN-AUTH-0002`. Too weak a sign-in is 403 `MAIN-AUTH-0003`. A sign-in older than `fresh` is 401 `MAIN-AUTH-0010`.

### Email and password

Passwords are a fallback; list `password` in `methods` to allow them. Sign-in sets the cookies from `issued.cookies`.

```ts title="app/api/sign-in/route.ts"
import { auth } from "mainsdk";

export async function POST(req: Request) {
  const { email, password } = (await req.json()) as { email: string; password: string };
  const issued = await auth.passwords.signIn(req, { email, password });
  const res = Response.json({ user: issued.user });
  for (const c of issued.cookies) res.headers.append("set-cookie", c);
  return res;
}
```

`auth.passwords.register(req, { email, password, name? })` makes the account and signs in. Every sign-in goes through the risk check below, so a password sign-in from a new device can answer `MAIN-AUTH-0050` instead of a session.

### Passkeys

The server half is four calls. Registration needs a sign-in from the last 10 minutes, so a stolen session cannot plant a passkey. The options are WebAuthn JSON for the browser's `navigator.credentials`.

```ts title="app/api/passkeys/route.ts"
import { auth, type RegistrationResponse } from "mainsdk";

export async function POST(req: Request) {
  const session = await auth.require(req);
  const body = (await req.json()) as Partial<RegistrationResponse>;
  if (!body.challengeId) {
    return Response.json(await auth.passkeys.registerOptions(session, { conditional: false }));
  }
  const passkey = await auth.passkeys.register(session, body as RegistrationResponse);   // { challengeId, response }
  return Response.json({ id: passkey.id });
}
```

```ts title="app/api/passkeys/sign-in/route.ts"
import { auth, type AssertionResponse } from "mainsdk";

export async function GET() {
  return Response.json(await auth.passkeys.signInOptions());      // no credential list: autofill works
}

export async function POST(req: Request) {
  const { challengeId, id, response } = (await req.json()) as AssertionResponse;
  const issued = await auth.passkeys.signIn(req, { challengeId, id, response });
  const res = Response.json({ user: issued.user, aal: issued.aal });   // "aal2"
  for (const c of issued.cookies) res.headers.append("set-cookie", c);
  return res;
}
```

The browser half passes the options to `navigator.credentials.create()` or `.get()` and posts the result back; the browser's own cross-device QR needs no extra code. That half is browser code and is not run in the repo's tests; the server half is run against a fake authenticator. `auth.passkeys.list(user)` and `auth.passkeys.remove(session, id)` manage them, and `auth.passkeys.report()` returns `{ users, withPasskey, share }`, the share of your users on passkeys.

### CLI device sign-in

For a CLI or a TV app, use the device authorization grant (RFC 8628). Codes expire in 10 minutes.

```ts title="cli/login.ts"
import { auth } from "mainsdk";

const grant = await auth.device.start({ scope: "cli" });
console.log(`Open ${grant.verificationUri} and enter ${grant.code} (expires in ${grant.expiresIn}s)`);

// the device polls with the secret deviceCode, never with the code the person types
const result = await auth.device.poll(grant.deviceCode);
if (result.status === "approved") console.log("signed in as", result.session.user);
```

`start()` returns `code` (what the person types), `deviceCode` (the secret the device polls with), `qr`, `verificationUri`, `expiresIn` (600) and `interval` (5). `poll()` returns `{ status }` of `authorization_pending` until the person approves, `slow_down` if you poll faster than the interval, `expired_token` after 600 seconds, `access_denied` if denied, and `{ status: "approved", session }` once approved. On your verification page, a signed-in person approves with `auth.device.approve(session, code)` or refuses with `auth.device.deny(session, code)`. The `qr` is drawn locally with `@getqr/react` `<QRCode/>`; it never becomes a getqr.id link.

### WhatsApp one-time codes

Codes are single use, expire after 5 minutes, close after 5 wrong tries, and are rate limited per phone and per IP. Bot and pumping signals run before any message is sent.

```ts title="app/sign-in/whatsapp/actions.ts"
"use server";
import { auth } from "mainsdk";

export async function sendCode(req: Request, phone: string) {
  const { challengeId } = await auth.whatsapp.send(phone, { req });
  return challengeId;
}

export async function verifyCode(req: Request, challengeId: string, code: string) {
  return auth.whatsapp.verify(challengeId, code, { req });   // throws MAIN-AUTH-0006 on reuse, expiry or too many tries
}
```

A new phone number makes a new account unless `signup: closed`. `verify()` returns an `Issued` session: send its `cookies` to the browser as above. Without a sender it fails with `MAIN-AUTH-0023`.

### Sign-in links

With `link` in `methods`, `auth.link.issue()` makes a single-use token for a verified email or phone, valid 15 minutes. It returns `null`, after the same work, when there is no such account, so the answer never shows who has one. Deliver the token yourself, for example through `links.signIn`.

```ts title="app/api/link/route.ts"
import { auth } from "mainsdk";

export async function POST(req: Request) {
  const { email } = (await req.json()) as { email: string };
  const issued = await auth.link.issue({ email }, { req, bind: "browser" });
  const res = new Response(null, { status: 202 });                 // 202 whether or not there is an account
  for (const c of issued?.cookies ?? []) res.headers.append("set-cookie", c);
  // send issued.token to the person by email
  return res;
}

export async function PUT(req: Request) {
  const { token } = (await req.json()) as { token: string };
  const issued = await auth.link.verify(req, token);               // MAIN-AUTH-0046 if used, expired or in another browser
  const res = Response.json({ user: issued.user });
  for (const c of issued.cookies) res.headers.append("set-cookie", c);
  return res;
}
```

A link session is `aal1`. A used, expired, unknown or wrongly bound token is `MAIN-AUTH-0046` and closes the challenge.

### Social sign-in

`auth.oauth.start()` sends a person to Google, GitHub or Microsoft with PKCE and `state`; `auth.oauth.callback()` finishes it. The implicit flow is not offered.

```ts title="app/auth/google/route.ts"
import { auth } from "mainsdk";

export async function GET() {
  const { url, cookies } = await auth.oauth.start("google", { redirectUri: "https://app.example.com/auth/callback" });
  const res = new Response(null, { status: 302, headers: { location: url } });
  for (const c of cookies) res.headers.append("set-cookie", c);
  return res;
}
```

```ts title="app/auth/callback/route.ts"
import { auth } from "mainsdk";

export async function GET(req: Request) {
  const issued = await auth.oauth.callback(req);                    // MAIN-AUTH-0007 on a bad state, nonce or token
  const res = new Response(null, { status: 302, headers: { location: issued.returnTo ?? "/" } });
  for (const c of issued.cookies) res.headers.append("set-cookie", c);
  return res;
}
```

### Risk and step-up

Every sign-in is scored in process on device, country, impossible travel, bot score, failed tries, blocked networks and, if you opt in, breached passwords. The answer is `allow`, `step-up` or `deny`. A password sign-in from a new device, for example, throws `MAIN-AUTH-0050` with the methods that satisfy it. Finish it with a second check; the session is issued only then.

```ts title="app/api/sign-in/route.ts"
import { auth, MainError } from "mainsdk";

export async function POST(req: Request) {
  const { email, password } = (await req.json()) as { email: string; password: string };
  try {
    const issued = await auth.passwords.signIn(req, { email, password });
    const res = Response.json({ user: issued.user });
    for (const c of issued.cookies) res.headers.append("set-cookie", c);
    return res;
  } catch (err) {
    if (err instanceof MainError && err.code === "MAIN-AUTH-0050") {
      return Response.json(err.toProblem(), { status: 401 });      // { stepUp, pending, challenge }
    }
    throw err;
  }
}
```

The browser then starts the second check and completes it:

```ts title="app/api/step-up/route.ts"
import { auth } from "mainsdk";

export async function POST(req: Request) {
  const { pending, challenge, challengeId, code } = (await req.json()) as { pending: string; challenge: string; challengeId: string; code: string };
  const issued = await auth.stepUp.complete(req, { pending, challenge, method: "whatsapp", challengeId, code });
  const res = Response.json({ aal: issued.aal });                   // "aal2"
  for (const c of issued.cookies) res.headers.append("set-cookie", c);
  return res;
}
```

`auth.stepUp.start({ pending, challenge, method })` sends the WhatsApp code or returns the passkey options first. A wrong, expired or reused step-up is `MAIN-AUTH-0052` and closes the pending sign-in. A blocked network is refused with `MAIN-AUTH-0051`. The challenge lasts 5 minutes. `auth.risk(req, { userId })` assesses before a sensitive step and returns `{ level, action, reasons, stepUp }`; `auth.onRisk(fn)` runs `fn` after each assessment and returns the function that stops it. With `risk.mode: observe` nothing is refused and each decision is logged.

### Trusted devices and recovery

Every new device triggers a notice to the person's other devices, and any device can be revoked in one action.

```ts title="app/api/devices/route.ts"
import { auth } from "mainsdk";

export async function GET(req: Request) {
  const session = await auth.require(req);
  return Response.json(await auth.devices(session.user));
}

export async function DELETE(req: Request) {
  const session = await auth.require(req, { fresh: "5m" });
  const { deviceId } = (await req.json()) as { deviceId: string };
  await auth.devices.revoke(session.user, deviceId);   // its sessions end on the next request
  return new Response(null, { status: 204 });
}
```

Recovery is never weaker than sign-in. Recovery codes (`auth.recover.generateCodes(session)`, ten, one use each) or a second passkey (`withCode`, `withPasskey`) come first. Otherwise a WhatsApp or email check plus a waiting period, which any existing device can cancel:

```ts title="app/api/recover/route.ts"
import { auth, type Session } from "mainsdk";

export async function recover(req: Request, userId: string, code: string, session: Session) {
  const { challengeId } = await auth.recover(userId, { via: "whatsapp", req });
  const { recovery, notBefore } = await auth.recover.confirm(challengeId, code);   // notBefore is 24 hours out
  await auth.recover.cancel(session);                  // from an existing device, inside the window
  return { back: await auth.recover.complete(req, recovery), notBefore };   // MAIN-AUTH-0019 until the wait has ended
}
```

Completing recovery ends every old session and device. Every recovery is written to the audit log. An unknown person gets the same answer as a known one.

### Single sign-on for a customer organisation

Turn on SSO per customer organisation. The organisation must first prove its domain by DNS (see below). Sign-in routes by verified email domain, and deprovisioning through SCIM ends sessions.

```ts title="server/sso.ts"
import { Hono } from "hono";
import { auth } from "mainsdk";

const app = new Hono();

app.post("/orgs/:org/sso", async (c) => {
  const session = await auth.require(c.req.raw, { role: "owner" });
  const scim = await auth.handle(c.req.raw, async () => {
    await auth.sso.enable({
      protocol: "oidc",
      issuer: "https://idp.pinecart.com",
      clientId: "pinecart-app",
      clientSecret: "secret:PINECART_OIDC_SECRET",     // a secret reference, never the value
      domains: ["pinecart.com"],
    });
    return auth.scim.enable({ groupRoles: { eng: "editor", finance: "viewer" } });
  });
  return c.json({ by: session.user, scimEndpoint: scim.endpoint, scimToken: scim.token });
});

app.post("/sign-in/sso", async (c) => {
  const { email } = (await c.req.raw.json()) as { email: string };
  const { url, cookies } = await auth.sso.signIn({ email }, { redirectUri: "https://app.example.com/auth/sso" });
  const res = new Response(null, { status: 302, headers: { location: url } });
  for (const cookie of cookies) res.headers.append("set-cookie", cookie);
  return res;
});

app.get("/auth/sso", async (c) => {
  const issued = await auth.sso.callback(c.req.raw);   // MAIN-AUTH-0007 on a mismatched state or nonce
  const res = new Response(null, { status: 302, headers: { location: issued.returnTo ?? "/" } });
  for (const cookie of issued.cookies) res.headers.append("set-cookie", cookie);
  return res;
});

export default app;
```

`auth.sso.enable()` and `auth.scim.enable()` act on the current organisation; `auth.handle(req, fn)` runs `fn` inside the organisation the session names. The SCIM token is shown once; give it to the customer's IdP with the endpoint, and mount `auth.scim.handle(req)` at that path. OIDC uses PKCE and `state`, and a mismatched `state` or `nonce` is refused with `MAIN-AUTH-0007`.

### SAML

SAML 2.0 is built into your app and works under a narrow profile: you start every sign-in, and the identity provider (IdP) answers it. Give the organisation's IdP metadata and its verified domains:

```ts title="app/admin/sso/saml.ts"
import { auth } from "mainsdk";

export async function enableSaml(acmeMetadataXml: string) {
  await auth.sso.enable("acme", {
    protocol: "saml",
    metadata: acmeMetadataXml,            // the XML, or an https URL it is read from
    domains: ["acme.com"],
    // entityId: "https://app.example.com/saml/acme"   // defaults to urn:mainsdk:sp:<organisation id>
  });
}
```

Two routes finish the job. Sign-in uses the same `auth.sso.signIn` call as OIDC, with `redirectUri` set to the URL of the second route:

```ts title="app/auth/saml.ts"
import { Hono } from "hono";
import { auth } from "mainsdk";

const app = new Hono();

// GET /auth/saml/metadata?org=acme: paste this into the IdP (or give it the URL)
app.get("/auth/saml/metadata", async (c) => {
  const org = new URL(c.req.raw.url).searchParams.get("org")!;
  const xml = await auth.sso.saml.metadata(org, { acsUrl: "https://app.example.com/auth/saml/acs" });
  return new Response(xml, { headers: { "content-type": "application/samlmetadata+xml" } });
});

// POST /auth/saml/acs: the IdP posts its response here
app.post("/auth/saml/acs", async (c) => {
  const issued = await auth.sso.saml.acs(c.req.raw);   // MAIN-AUTH-0008, with a `reason`, on any refusal
  const res = new Response(null, { status: 302, headers: { location: issued.returnTo ?? "/" } });
  for (const cookie of issued.cookies) res.headers.append("set-cookie", cookie);
  return res;
});

export default app;
```

What is accepted, and nothing else:

- **App-initiated only.** Sign-in starts at `auth.sso.signIn`; a response with no pending request, or from another browser, is refused. The request works once, and its cookie is `SameSite=None` because the IdP answers with a cross-site POST.
- **One signature, from your metadata.** The signature must cover the assertion, or the response holding it, and verify with a certificate listed in the IdP metadata (list two while the IdP rotates). `KeyInfo` in the response is never used. RSA or ECDSA P-256 with SHA-256 or SHA-512; SHA-1 is refused.
- **Checked every time:** issuer, destination, recipient, `InResponseTo`, status, validity (60 seconds of clock skew) and audience, which is your entity ID. The person's address must be at a domain the organisation verified.
- **Never twice.** Each assertion ID is kept in your database until it can no longer be replayed. If that store cannot answer, the sign-in is refused (the one place `auth` fails closed instead of static) and nothing else in your app stops.
- **Refused with a fix:** encrypted assertions (turn encryption off at the IdP), IdPs that require signed requests, POST-only IdPs, documents over 256 KB or 64 levels deep, and any DTD or entity.

Every refusal is `MAIN-AUTH-0008` with a fixed `reason` such as `unsigned`, `audience`, `expired`, `replay` or `signature-signature`, and an audit record `auth.sso_refused`.

## Organisations, members, teams and invites

An organisation is a tenant of kind `org` (see [tenants](https://mainsdk.dev/docs/interfaces/tenant)). Calls act on the current tenant, so run them inside `auth.handle(req, fn)`. A personal tenant has no members, teams, invites or domains: those calls fail with `MAIN-AUTH-0045`.

### Create and switch organisations

```ts title="app/api/orgs/route.ts"
import { auth } from "mainsdk";

export async function POST(req: Request) {
  const { name } = (await req.json()) as { name: string };
  const org = await auth.handle(req, () => auth.orgs.create({ name, region: "eu" }));   // the creator becomes owner
  return Response.json(org);
}

export async function GET(req: Request) {
  return Response.json(await auth.handle(req, () => auth.orgs.list()));   // personal and org tenants this person belongs to
}

export async function PUT(req: Request) {
  const { tenantId } = (await req.json()) as { tenantId: string };
  return Response.json(await auth.orgs.switch(req, tenantId));   // 404 MAIN-TENANT-0002 if not a member
}
```

### Members and teams

```ts title="server/members.ts"
import { auth } from "mainsdk";

export async function manage(req: Request, userId: string) {
  await auth.handle(req, async () => {
    const page = await auth.members.list({ limit: 50 });         // { items }
    await auth.members.setRoles(userId, ["admin"]);
    await auth.members.remove(userId);                           // the last owner cannot go: MAIN-AUTH-0043

    const eng = await auth.teams.create("eng");
    await auth.teams.add(eng.id, userId);
    await auth.teams.list();
    await auth.teams.remove(eng.id, userId);
    return page;
  });
}
```

A team can be an access subject: `access.relate(team, "editor", folder)` gives every member of the team that role.

### Invites

An invite is bound to one email address, works once, expires in 7 days and goes out by email as a getqr.id link. Accepting it with another address fails with `MAIN-AUTH-0041` and the invite stays valid.

```ts title="app/api/invites/route.ts"
import { auth } from "mainsdk";

export async function POST(req: Request) {
  const { email } = (await req.json()) as { email: string };
  const invite = await auth.handle(req, () => auth.invites.create({ email, roles: ["member"], expiresIn: "7d" }));
  return Response.json({ id: invite.id, url: invite.url, expiresAt: invite.expiresAt });
}

export async function PUT(req: Request) {
  const token = new URL(req.url).searchParams.get("invite")!;     // the link opens your app with ?invite=<token>
  return Response.json(await auth.invites.accept(req, token));     // the person joins with the invited roles
}

export async function DELETE(req: Request) {
  const { inviteId } = (await req.json()) as { inviteId: string };
  await auth.handle(req, () => auth.invites.revoke(inviteId));
  return new Response(null, { status: 204 });
}
```

Creating an invite needs an email driver (`MAIN-AUTH-0025` without one). Only an owner can invite an owner.

### Domains and required SSO

Prove an email domain by DNS, then let people who sign in with a verified email on it join on their own. `auth.sso.require()` makes members enter an organisation only through its SSO.

```ts title="server/domains.ts"
import { auth } from "mainsdk";

export async function verifyDomain(req: Request) {
  await auth.handle(req, async () => {
    const proof = await auth.domains.add("acme.com");                  // { record, value }: publish this TXT record
    await auth.domains.verify("acme.com");                             // MAIN-AUTH-0042 until the record is found
    await auth.domains.autoJoin("acme.com", { role: "member" });       // or false to turn it off
    await auth.sso.require({ except: ["owner"] });                     // others get MAIN-AUTH-0044 without SSO
    return proof;
  });
}
```

`auth.sso.require(tenantId, opts)` takes a tenant id when you are outside `auth.handle`. A public email domain such as gmail.com is refused, and so is a domain another tenant has verified.

### Wallet sign-in

Wallet sign-in by challenge signature (`auth.challenge.start()` and `auth.challenge.verify()`) is planned for phase 2 (spec `web3-ready`). It is not built yet.

## Reference

Calls and helpers that the sections above do not show in full.

| Call | What it does |
| --- | --- |
| `createAuth(options)` | Builds the `Auth` object from the app's pieces (config, store, tenancy, log, WhatsApp sender). The runtime calls it for you; call it yourself in tests or a custom host |
| `new Auth(options)` | The class `createAuth` returns. Prefer `createAuth` |
| `auth.recover.withCode(req, user, code)` | Recovers with one recovery code. The code is spent, and the sign-in is risk-assessed but never stepped up. Limited to 10 tries in 15 minutes per person |
| `auth.recover.withPasskey(req, user, assertion)` | Recovers with a second passkey: the same proof as a passkey sign-in, for a named person |
| `auth.recover.codesLeft(user)` | How many recovery codes the person has left, as a number. Show it and offer `generateCodes` when it runs low |
| `auth.sso.get()` | The single sign-on connection of the current organisation, or `null` |
| `new StepUpRequired({ pending, challenge, stepUp })` | The `MAIN-AUTH-0050` error (401) thrown when a second method is needed before a session is issued. `stepUp` names the methods that will do; finish one within 5 minutes |
| `assessRisk(input, config)` | The pure risk function behind `auth.risk`. Returns the would-be action (allow, step up or deny) with its reasons, even in observe mode |
| `auth.challenge.start({ method: "wallet" })` | Makes a single-use message bound to the app domain, valid for five minutes at most. Needs `wallet` in `auth.methods` and a wallet driver; returns `{ nonce, message }` |
| `auth.challenge.verify({ message, signature }, req?)` | The wallet driver checks the signature; the person behind the address signs in with the same session as any method. A used or expired challenge fails with `MAIN-AUTH-0040` (401) |
| `isPhishingResistantMethod(method)` | True for `passkeys` and `sso-phr` |
| `normalizePhone(input)` | Returns an E.164 number or throws `MAIN-AUTH-0014` (400) |
| `maskPhone(e164)` | Hides the middle of an E.164 number, keeping the country prefix and last four digits, for logs and screens |

## Errors and checks

Errors are `MainError` values that serialise as [RFC 9457 problem details](https://mainsdk.dev/docs/reference/errors) with a `code`, a `fix` and a docs link.

| Code | Status | When | Fix |
| --- | --- | --- | --- |
| `MAIN-AUTH-0001` | 401 | No valid session | Sign in, or call `auth.session()` where signing in is optional |
| `MAIN-AUTH-0002` | 403 | The session lacks the role | Ask an owner of the organisation to give the role |
| `MAIN-AUTH-0003` | 403 | The sign-in is too weak for `aal` or an admin role | Sign in with a passkey, or complete a second check |
| `MAIN-AUTH-0006` | 401 | WhatsApp code reused, expired or tried too often | Send a new code |
| `MAIN-AUTH-0007` | 401 | OIDC or social callback with a bad `state`, `nonce` or token | Start sign-in again from your app |
| `MAIN-AUTH-0010` | 401 | Sign-in older than `fresh` | Ask the person to sign in again |
| `MAIN-AUTH-0011` | 500 | `auth.key` is not set in production | Set `auth.key: secret:AUTH_KEY` |
| `MAIN-AUTH-0020` | 503 | The store or database could not be reached | Retry; check the `kv` and `db` drivers |
| `MAIN-AUTH-0008` | 401 | A SAML response was refused; `reason` says why | Check the IdP metadata, certificate and clock for the organisation |
| `MAIN-AUTH-0041` to `0045` | 4xx | Invite, domain, last owner, required SSO and personal-tenant refusals | Each problem body carries a `fix` |
| `MAIN-AUTH-0046` | 401 | Sign-in link used, expired, unknown or in another browser | Ask for a new link in that browser |
| `MAIN-AUTH-0050` | 401 | Step-up needed before a session is issued | Complete one of the listed methods within 5 minutes |
| `MAIN-AUTH-0051` | 403 | Source network is blocked | Sign in from another network |
| `MAIN-AUTH-0052` | 401 | Step-up wrong, expired or reused | Start the sign-in again |
| `MAIN-AUTH-0054` | 422 | The new password is in a known breach | Choose another password |

```json title="401 response"
{
  "type": "https://mainsdk.dev/errors/MAIN-AUTH-0001",
  "title": "Not signed in",
  "status": 401,
  "code": "MAIN-AUTH-0001",
  "fix": "Sign in, then retry; the session cookie is missing, expired or revoked",
  "retryable": false,
  "trace_id": "4bf92f3577b34da6a3ce929d0e0e4736"
}
```

`mainsdk check` fails with rule `auth.admin_phishing_resistant` (code `MAIN-AUTH-0012`) when a role under `auth.admin.roles` can sign in with weak methods and `admin.require` is not `phishing-resistant`. It fails with `MAIN-AUTH-0053` when `risk.step-up` names a method that is not in `methods`, or holds only methods weaker than the ones it steps up from. Run `mainsdk explain MAIN-AUTH-0001` for the cause and fix offline.

## Swapping drivers

Provider drivers are planned. The calls on this page are the contract they will keep, so moving to Clerk, Auth0, Better Auth, WorkOS or Supabase Auth will be one line in `main.yaml`. The WhatsApp sender is a driver today and swaps that way, under `auth.whatsapp.driver`. See [swap a driver](https://mainsdk.dev/docs/guides/swap-a-driver).

> **Warning:** Identity is stateful. Your users live in the driver's store, so moving them to another provider is a migration, not a switch. Check the new driver's capability profile first: `mainsdk check` names any method it lacks.

`auth.signOut(req)` ends this browser's session and returns the `Set-Cookie` lines that clear its cookies; `auth.signOutEverywhere(req)` ends every session of the person on every device.

`auth.raw()` is the escape hatch for provider-only features. With the built-in driver there is no outside provider; it returns `{ driver: "local" }` and logs `MAIN-AUTH-0012`.

## Testing

`createTestApp()` puts every interface on its in-memory fake, so tests need no provider account. See [the testing kit](https://mainsdk.dev/docs/interfaces/testing). `@mainsdk/testing` also ships a fake passkey authenticator (`createFakeAuthenticator`), a fake OIDC provider (`createFakeOidcProvider`) and a fake WhatsApp sender (`fakeWhatsApp`).

```ts title="app/api/me/route.test.ts"
import { describe, it } from "node:test";
import assert from "node:assert/strict";
import { createTestApp } from "@mainsdk/testing";
import { auth, problem } from "mainsdk";

// the handler under test, as in app/api/me/route.ts
async function GET(req: Request) {
  try {
    const session = await auth.require(req);
    return Response.json({ user: session.user });
  } catch (err) {
    return problem(err);
  }
}

describe("GET /api/me", () => {
  it("auth AC1 refuses a request with no session and accepts one", async () => {
    createTestApp({
      yaml: "schema: 1\nproject: shop\nauth:\n  methods: [password]\n",
    });
    const none = await GET(new Request("http://localhost/api/me"));
    assert.equal(none.status, 401);
    assert.equal(((await none.json()) as { code: string }).code, "MAIN-AUTH-0001");

    const issued = await auth.passwords.register(new Request("http://localhost/"), {
      email: "jane@x.com",
      password: "correct horse battery",
    });
    const cookie = issued.cookies.map((c) => c.split(";")[0]).join("; ");
    const ok = await GET(new Request("http://localhost/api/me", { headers: { cookie } }));
    assert.equal(ok.status, 200);
  });
});
```

The sdk's own tests in `packages/sdk/test/auth*.test.ts` show the full set: organisations, SCIM, risk, recovery and the fake identity provider.

## Security and privacy

- Sessions are short-lived, HttpOnly, Secure, SameSite cookies. Tokens are never written to `localStorage`, and the implicit OAuth flow is not offered.
- A refresh token works once. A reused token revokes the whole token family. Sessions are bound to a device through a signed device cookie and a rotating refresh family; Device Bound Session Credentials (DBSC) are planned.
- Email and phone are sealed at rest and found by keyed hash.
- Enterprise sign-in for your customers is a free capability in your app. It is not SSO for the MainSDK Cloud console.

## FAQ

**Can I keep passwords?** Yes, as a fallback. Passkeys are the default; list `password` in `methods` to allow them. Admin roles still need a phishing-resistant method.

**Does MainSDK store my users?** Today, yes, in your own `kv` driver, sealed at rest. A provider driver will keep them in the provider's tenant instead.

**Do I get SAML and SCIM?** Yes: SAML 2.0 (see [SAML](#saml)), OIDC single sign-on and SCIM 2.0 all work in your app.

**What about native apps and app-to-desktop QR?** Native passkeys, app-to-desktop QR and push approval are phase 3.

**Which provider drivers ship first?** Clerk and Better Auth are proposed to make `auth` stable in 0.3; that choice is still open.

## Next

- [Permissions](https://mainsdk.dev/docs/interfaces/access): decide what a signed-in user can do.
- [Data and records](https://mainsdk.dev/docs/interfaces/db): read records scoped to the user.
- [Rate limits and quotas](https://mainsdk.dev/docs/interfaces/limits): the limits behind sign-in.
- [Tenants](https://mainsdk.dev/docs/interfaces/tenant): what an organisation is.
- [API keys for your customers](https://mainsdk.dev/docs/interfaces/apikeys): keys for code, not people.
