# Magic link, WebAuthn and passkeys

> Magic-link login on iam.svc end to end — routes, code delivery, TTL and single-use semantics — and the WebAuthn/passkey flow, which the deployable service does not enable.

<!-- source: https://docs.kis.ai/blocks/baas/iam/authn/passwordless/ -->

`iam.svc` ships two passwordless code paths. **Magic link works end to end**: two public
routes, a one-time code delivered through [notify.svc](/blocks/baas/notify/), and the same
login response password login returns. **WebAuthn and passkeys do not run in the deployable
service** — all four routes answer `503 webauthn_disabled`, and no config key, environment
variable or YAML file changes that; the relying-party identity is accepted only through a Go
construction option the shipped service never calls.

Both flows authenticate against the base `users` entity in the default realm. Neither
consults a realm's `providers:` list — see [Realms and providers](/blocks/baas/iam/authn/realms/).

## Magic link

| Method | Path | Auth | Rate-limit profile |
|---|---|---|---|
| `POST` | `/auth/magic/request` | public | `auth_magic` |
| `POST` | `/auth/magic/verify` | the code is the credential | `auth_magic` |

Both routes require the four CEPT headers (`X-Customer`, `X-Product`, `X-Env`, `X-Tenant`).
The `auth_magic` bucket is 15 requests capacity refilling at 0.25 per second, keyed per
(tenant, caller IP), in memory and per instance.

### Request a code

```http
POST /auth/magic/request
Content-Type: application/json
X-Customer: acme
X-Product: iam
X-Env: prod
X-Tenant: main
```

```json
{ "email": "user@example.com" }
```

```json
{ "ok": true }
```

The response is `200 {"ok": true}` for a registered email, an unregistered email, and a
malformed body alike. Nothing in the response distinguishes them, and no code is generated
for an address that matches no `users` row.

On a match the service:

1. Generates 32 random bytes, base64url-encoded — the plaintext code.
2. Writes a `user_magic_link` row holding `hex(SHA-256(code))` in `token`, `provider` `"email"`, `approved` `false`, and `expiresat` = now + 15 minutes.
3. Hands the plaintext to the notification sink with purpose `magic_link` and channel `email`.
4. Emits `after_magic_request` carrying `sent` and, on delivery failure, `sink_error`.

:::caution
The code is a ~43-character base64url string, not a six-digit PIN. Design the message
template for a long opaque string, and expect users to click a link rather than retype it.
:::

The order of checks is worth knowing when you test the endpoint: a body that is not valid
JSON, or one with an empty `email`, returns `200 {"ok": true}` **before** the tenant is
resolved. A well-formed body with no CEPT headers returns `400 no_tenant`.

`before_magic_request` fires after the tenant check and before the user lookup, and can
abort the request. An abort is not visible to the caller — the endpoint still answers
`200 {"ok": true}`. Internal failures are the only thing that breaks the uniform 200:
`500 engine_unavailable`, `500 code_failed` and `500 persist_failed` are all reachable once
the tenant resolves.

### Delivery

Delivery is a service-level concern, not a per-product one. The sink is wired from the
`notify.url` service config key:

| `notify.url` | Behaviour |
|---|---|
| set | `POST {notify.url}/notify/email/sync`, carrying the four CEPT headers and a per-tenant service token minted from that tenant's own signing key; upgraded to mTLS when a managed TLS config is registered for the service |
| unset | Codes are generated, hashed and persisted; nothing is delivered; the endpoint still returns `200` |

The outbound body:

```json
{
  "tenantkey": "acme:prod:iam:main",
  "templatename": "magic_link",
  "data": {
    "code": "<plaintext code>",
    "user_id": "U01K…",
    "user_email": "user@example.com",
    "user_firstname": "…",
    "user_lastname": "…",
    "user_displayname": "…",
    "expires_in_minutes": 15
  },
  "provider_context": { "to": "user@example.com", "user_id": "U01K…" },
  "idempotency_key": "01J…"
}
```

`templatename` is always the literal `magic_link`. The `template:` field on a `magic`-type
provider declaration in `providers.yaml` is parsed and stored but never read — renaming or
removing it changes nothing on the wire.

:::caution
An unwired sink is invisible from the API. With `notify.url` unset, `/auth/magic/request`
returns `200 {"ok": true}` and no mail is ever sent. The only signal is the
`after_magic_request` event, where `sent` is `false` and `sink_error` carries the reason.
Check that event before concluding delivery works.
:::

### Verify the code

```http
POST /auth/magic/verify
```

```json
{ "email": "user@example.com", "code": "<code from the email>" }
```

Both fields are required; omitting either is `400 missing_fields`. The handler resolves the
user by email, matches a row on `(userid, hex(SHA-256(code)), provider="email")`, checks
`expiresat`, deletes the row, then runs the MFA gate and mints.

The success body is identical in shape to a password login:

```json
{
  "token": "v4.public.…",
  "refresh_token": "01J…ULID.<base64url-secret>",
  "expires_in": 900,
  "user": { "id": "U01K…", "email": "user@example.com", "displayname": "…" }
}
```

A user with TOTP enrolled gets the second-step response instead — a magic link does not
bypass a second factor:

```json
{ "mfa_required": true, "mfa_token": "v4.public.…", "methods": ["totp"] }
```

Complete it at `POST /auth/mfa/verify`; see [Multi-factor authentication](/blocks/baas/iam/authn/mfa/).

On a tenant configured for managed sessions the response is the managed shape
(`session_id`, `expires_at`, `user`) plus the `kis_session` cookie, recorded with AAL 1 and
auth method `magic_link`. See [Sessions](/blocks/baas/iam/tokens/sessions/).

Single-use and expiry are enforced by deleting the row on success and by a fail-closed
expiry read — a missing, null or non-timestamp `expiresat` counts as expired rather than as
unexpired.

On success the flow emits `after_magic_verify` and then `after_login_success` with
`flow: "magic_link"`.

| Status | Code | Cause |
|---|---|---|
| 400 | `bad_request` | Body is not valid JSON |
| 400 | `missing_fields` | `email` or `code` absent |
| 400 | `no_tenant` | CEPT headers absent |
| 401 | `invalid_magic` | Unknown email, no matching code hash, expired, or already consumed — one envelope for all four |
| 401 | `invalid_token` | The identity row disappeared between verify and mint |
| 500 | `engine_unavailable` | Tenant datastore unreachable |
| 500 | `delete_failed` | Could not consume the row |
| 500 | `mfa_lookup_failed` | Could not read the TOTP enrolment |
| 500 | `sign_failed` / `issue_failed` | Token signing or refresh persistence failed |

Managed-session tenants can additionally return `503 policy_unavailable`,
`500 session_service_unavailable`, `409 session_limit` and `500 session_failed`. The full
list is on the [error reference](/blocks/baas/iam/error/).

### Limits of the magic-link flow

- **Email only.** The request body accepts one field, `email`; the stored `provider` is the constant `"email"`; the sink channel is the literal `"email"`. There is no SMS, WhatsApp, push or in-app magic link.
- **There is no OTP login provider anywhere in the block.** Magic link is the only one-time-code login that ships. A `user_otp` table exists in the schema and is read by no code.
- **Realm-blind.** The flow hard-codes the default realm `users` and queries the base `users` entity. Listing or omitting a `magic` provider in a realm's `providers:` list has no effect — that gate is consulted only by password login. A realm bound to its own entity cannot be reached by magic link.
- **TTL is fixed at 15 minutes** in the deployable service. No YAML key sets it; the override is a Go option the service never calls.
- **The stored `approved` flag is written `false` and never read**, and the entity's `numbers` column is neither written nor read. Verification matches on the code hash and expiry alone.
- **The row's `realm` column holds the tenant key**, not the authentication realm.
- **`before_magic_verify` is a declared event that nothing emits.** A hook registered on it never fires; gate the flow at `before_magic_request` instead.

## WebAuthn and passkeys

**The deployable `iam.svc` does not enable WebAuthn.** All four routes return
`503 webauthn_disabled` as the first statement in the handler — before the authentication
check, before body parsing, before any hook — and there is no configuration path that turns
them on. The relying-party identity (RP ID, display name, accepted origins) is accepted only
through a Go option at handler construction, which the shipped service never calls, so
enabling it means building a variant of the service.

| Method | Path | Auth | Response in the shipped service |
|---|---|---|---|
| `POST` | `/auth/webauthn/register/begin` | authenticated | `503 webauthn_disabled` |
| `POST` | `/auth/webauthn/register/finish` | authenticated | `503 webauthn_disabled` |
| `POST` | `/auth/webauthn/assert/begin` | public | `503 webauthn_disabled` |
| `POST` | `/auth/webauthn/assert/finish` | public | `503 webauthn_disabled` |

None of the four is rate-limited.

The rest of this section describes the flow as implemented, for anyone reading the credential
schema or building against a variant of the service that wires it.

### Registration

`register/begin` takes `{"name": "<label for the authenticator>"}` from an authenticated
caller, loads that user's existing credentials, starts the ceremony, and persists the
challenge in `webauthn_session` under the name `register:<name>`:

```json
{ "session_id": "01J…", "options": { "publicKey": { "challenge": "…", "rp": { "id": "…" } } } }
```

`register/finish` takes `{"session_id", "response"}` — `response` being the raw authenticator
attestation JSON — validates it against the stored challenge, writes a `webauthn_credential`
row (`userid`, `name` stripped of the `register:` prefix, `credential_id`, the serialized
credential, and the tenant key in the `realm` column), and deletes the session row. The
response body is `{"ok": true}`; the credential id is **not** returned, despite what the
handler's own comment says. It is carried on the `after_webauthn_register` event as
base64url.

### Assertion

`assert/begin` takes `{"email"}`, resolves the user, loads their credentials, and stores the
challenge under `assert:<userid>`. It returns the same `{session_id, options}` envelope. An
email that matches no user and a user with no registered credentials both return
`401 no_credentials`.

`assert/finish` takes `{"session_id", "response"}`, rejects a session id whose stored name is
not an `assert:` session, validates the assertion, deletes the session row, emits
`after_webauthn_assert`, runs the MFA gate, and mints. The success and MFA-required bodies
are identical to the magic-link ones. A failed assertion emits `after_login_failure` with
reason `webauthn_invalid_credential`. In managed mode the session records AAL 2 and auth
method `webauthn`.

| Status | Code | Cause |
|---|---|---|
| 503 | `webauthn_disabled` | Relying-party config absent — the shipped service's answer on every route |
| 401 | `auth_required` | A register route called with no credential |
| 400 | `missing_fields` | `name`, `email`, or `session_id` absent |
| 400 | `no_tenant` | CEPT headers absent on an assert route |
| 403 | `hook_denied` | A `before_webauthn_register` or `before_webauthn_assert` hook aborted |
| 401 | `invalid_session` | Ceremony session not found, or an assert-finish presented a register session |
| 401 | `no_credentials` | No credentials for this account, or the email matches no user |
| 400 | `invalid_response` | Authenticator response body could not be parsed |
| 401 | `webauthn_finish_failed` | Attestation or assertion verification failed |
| 500 | `engine_unavailable` / `user_load_failed` / `webauthn_begin_failed` / `session_persist_failed` / `persist_failed` | Datastore or ceremony-library failure |

### Limits of the WebAuthn flow

- **No discoverable-credential / autofill login.** `assert/begin` requires an email; the credential row stores no user handle, so a username-less ceremony is not possible.
- **User verification is fixed** at the library default of `preferred`. There is no option requiring UV, so an assertion is not guaranteed to carry a verified user.
- **Credentials per user are capped at 50 on load, silently.** A user with more registered authenticators will find some of them never offered, and a credential whose stored JSON fails to parse is skipped without error.
- **Ceremony sessions are never expired or swept.** `webauthn_session` rows carry no expiry check on load and no sweeper runs; an abandoned ceremony leaves its challenge in the table indefinitely. Rows are removed only when a `finish` call succeeds.
- **Realm-blind**, like magic link: always the base `users` entity in the default realm. The `realm` column on `webauthn_credential` holds the tenant key.
- **Not usable as a second factor.** The MFA gate has exactly one method, TOTP; a registered passkey does not satisfy it.

## Continue with

- [Password login](/blocks/baas/iam/authn/password/) — identifier columns, the realm gate and the login response
- [Multi-factor authentication](/blocks/baas/iam/authn/mfa/) — the TOTP enrolment and the second step
- [Tokens](/blocks/baas/iam/tokens/) — the claim set, lifetimes and refresh rotation
- [Operating the block](/blocks/baas/iam/operating/configuration/) — `notify.url` and the rest of the service config
- [IAM API reference](/blocks/baas/iam/api/) — every endpoint, request and response
- [Error reference](/blocks/baas/iam/error/) — the full `(status, code)` table