{
  "docId": "baas.iam.authn.passwordless",
  "title": "Magic link, WebAuthn and passkeys",
  "summary": "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.",
  "url": "https://docs.kis.ai/blocks/baas/iam/authn/passwordless/",
  "markdown": "https://docs.kis.ai/blocks/baas/iam/authn/passwordless.md",
  "product": "iam",
  "area": "guide",
  "documentType": "guide",
  "status": "active",
  "authority": "canonical",
  "visibility": "public",
  "documentationVersion": "2026.07",
  "productVersion": "2.0.0",
  "apiContractVersion": null,
  "httpPathVersioning": "none",
  "intent": [],
  "audience": [],
  "lastModified": "2026-07-29",
  "lastReviewed": null,
  "owners": [],
  "appliesTo": [
    ">=2.0.0 <2.1.0"
  ],
  "prerequisites": [],
  "related": [],
  "supersedes": [],
  "supersededBy": null,
  "ai": {
    "discoverable": true,
    "retrievable": true,
    "authoritative": true,
    "chunking": "heading",
    "answerableQuestions": []
  },
  "headings": [
    {
      "depth": 2,
      "text": "Magic link",
      "id": "magic-link"
    },
    {
      "depth": 3,
      "text": "Request a code",
      "id": "request-a-code"
    },
    {
      "depth": 3,
      "text": "Delivery",
      "id": "delivery"
    },
    {
      "depth": 3,
      "text": "Verify the code",
      "id": "verify-the-code"
    },
    {
      "depth": 3,
      "text": "Limits of the magic-link flow",
      "id": "limits-of-the-magic-link-flow"
    },
    {
      "depth": 2,
      "text": "WebAuthn and passkeys",
      "id": "webauthn-and-passkeys"
    },
    {
      "depth": 3,
      "text": "Registration",
      "id": "registration"
    },
    {
      "depth": 3,
      "text": "Assertion",
      "id": "assertion"
    },
    {
      "depth": 3,
      "text": "Limits of the WebAuthn flow",
      "id": "limits-of-the-webauthn-flow"
    },
    {
      "depth": 2,
      "text": "Continue with",
      "id": "continue-with"
    }
  ],
  "wordCount": 1970,
  "body": "`iam.svc` ships two passwordless code paths. **Magic link works end to end**: two public\nroutes, a one-time code delivered through [notify.svc](/blocks/baas/notify/), and the same\nlogin response password login returns. **WebAuthn and passkeys do not run in the deployable\nservice** — all four routes answer `503 webauthn_disabled`, and no config key, environment\nvariable or YAML file changes that; the relying-party identity is accepted only through a Go\nconstruction option the shipped service never calls.\n\nBoth flows authenticate against the base `users` entity in the default realm. Neither\nconsults a realm's `providers:` list — see [Realms and providers](/blocks/baas/iam/authn/realms/).\n\n## Magic link\n\n| Method | Path | Auth | Rate-limit profile |\n|---|---|---|---|\n| `POST` | `/auth/magic/request` | public | `auth_magic` |\n| `POST` | `/auth/magic/verify` | the code is the credential | `auth_magic` |\n\nBoth routes require the four CEPT headers (`X-Customer`, `X-Product`, `X-Env`, `X-Tenant`).\nThe `auth_magic` bucket is 15 requests capacity refilling at 0.25 per second, keyed per\n(tenant, caller IP), in memory and per instance.\n\n### Request a code\n\n```http\nPOST /auth/magic/request\nContent-Type: application/json\nX-Customer: acme\nX-Product: iam\nX-Env: prod\nX-Tenant: main\n```\n\n```json\n{ \"email\": \"user@example.com\" }\n```\n\n```json\n{ \"ok\": true }\n```\n\nThe response is `200 {\"ok\": true}` for a registered email, an unregistered email, and a\nmalformed body alike. Nothing in the response distinguishes them, and no code is generated\nfor an address that matches no `users` row.\n\nOn a match the service:\n\n1. Generates 32 random bytes, base64url-encoded — the plaintext code.\n2. Writes a `user_magic_link` row holding `hex(SHA-256(code))` in `token`, `provider` `\"email\"`, `approved` `false`, and `expiresat` = now + 15 minutes.\n3. Hands the plaintext to the notification sink with purpose `magic_link` and channel `email`.\n4. Emits `after_magic_request` carrying `sent` and, on delivery failure, `sink_error`.\n\n:::caution\nThe code is a ~43-character base64url string, not a six-digit PIN. Design the message\ntemplate for a long opaque string, and expect users to click a link rather than retype it.\n:::\n\nThe order of checks is worth knowing when you test the endpoint: a body that is not valid\nJSON, or one with an empty `email`, returns `200 {\"ok\": true}` **before** the tenant is\nresolved. A well-formed body with no CEPT headers returns `400 no_tenant`.\n\n`before_magic_request` fires after the tenant check and before the user lookup, and can\nabort the request. An abort is not visible to the caller — the endpoint still answers\n`200 {\"ok\": true}`. Internal failures are the only thing that breaks the uniform 200:\n`500 engine_unavailable`, `500 code_failed` and `500 persist_failed` are all reachable once\nthe tenant resolves.\n\n### Delivery\n\nDelivery is a service-level concern, not a per-product one. The sink is wired from the\n`notify.url` service config key:\n\n| `notify.url` | Behaviour |\n|---|---|\n| 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 |\n| unset | Codes are generated, hashed and persisted; nothing is delivered; the endpoint still returns `200` |\n\nThe outbound body:\n\n```json\n{\n  \"tenantkey\": \"acme:prod:iam:main\",\n  \"templatename\": \"magic_link\",\n  \"data\": {\n    \"code\": \"<plaintext code>\",\n    \"user_id\": \"U01K…\",\n    \"user_email\": \"user@example.com\",\n    \"user_firstname\": \"…\",\n    \"user_lastname\": \"…\",\n    \"user_displayname\": \"…\",\n    \"expires_in_minutes\": 15\n  },\n  \"provider_context\": { \"to\": \"user@example.com\", \"user_id\": \"U01K…\" },\n  \"idempotency_key\": \"01J…\"\n}\n```\n\n`templatename` is always the literal `magic_link`. The `template:` field on a `magic`-type\nprovider declaration in `providers.yaml` is parsed and stored but never read — renaming or\nremoving it changes nothing on the wire.\n\n:::caution\nAn unwired sink is invisible from the API. With `notify.url` unset, `/auth/magic/request`\nreturns `200 {\"ok\": true}` and no mail is ever sent. The only signal is the\n`after_magic_request` event, where `sent` is `false` and `sink_error` carries the reason.\nCheck that event before concluding delivery works.\n:::\n\n### Verify the code\n\n```http\nPOST /auth/magic/verify\n```\n\n```json\n{ \"email\": \"user@example.com\", \"code\": \"<code from the email>\" }\n```\n\nBoth fields are required; omitting either is `400 missing_fields`. The handler resolves the\nuser by email, matches a row on `(userid, hex(SHA-256(code)), provider=\"email\")`, checks\n`expiresat`, deletes the row, then runs the MFA gate and mints.\n\nThe success body is identical in shape to a password login:\n\n```json\n{\n  \"token\": \"v4.public.…\",\n  \"refresh_token\": \"01J…ULID.<base64url-secret>\",\n  \"expires_in\": 900,\n  \"user\": { \"id\": \"U01K…\", \"email\": \"user@example.com\", \"displayname\": \"…\" }\n}\n```\n\nA user with TOTP enrolled gets the second-step response instead — a magic link does not\nbypass a second factor:\n\n```json\n{ \"mfa_required\": true, \"mfa_token\": \"v4.public.…\", \"methods\": [\"totp\"] }\n```\n\nComplete it at `POST /auth/mfa/verify`; see [Multi-factor authentication](/blocks/baas/iam/authn/mfa/).\n\nOn a tenant configured for managed sessions the response is the managed shape\n(`session_id`, `expires_at`, `user`) plus the `kis_session` cookie, recorded with AAL 1 and\nauth method `magic_link`. See [Sessions](/blocks/baas/iam/tokens/sessions/).\n\nSingle-use and expiry are enforced by deleting the row on success and by a fail-closed\nexpiry read — a missing, null or non-timestamp `expiresat` counts as expired rather than as\nunexpired.\n\nOn success the flow emits `after_magic_verify` and then `after_login_success` with\n`flow: \"magic_link\"`.\n\n| Status | Code | Cause |\n|---|---|---|\n| 400 | `bad_request` | Body is not valid JSON |\n| 400 | `missing_fields` | `email` or `code` absent |\n| 400 | `no_tenant` | CEPT headers absent |\n| 401 | `invalid_magic` | Unknown email, no matching code hash, expired, or already consumed — one envelope for all four |\n| 401 | `invalid_token` | The identity row disappeared between verify and mint |\n| 500 | `engine_unavailable` | Tenant datastore unreachable |\n| 500 | `delete_failed` | Could not consume the row |\n| 500 | `mfa_lookup_failed` | Could not read the TOTP enrolment |\n| 500 | `sign_failed` / `issue_failed` | Token signing or refresh persistence failed |\n\nManaged-session tenants can additionally return `503 policy_unavailable`,\n`500 session_service_unavailable`, `409 session_limit` and `500 session_failed`. The full\nlist is on the [error reference](/blocks/baas/iam/error/).\n\n### Limits of the magic-link flow\n\n- **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.\n- **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.\n- **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.\n- **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.\n- **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.\n- **The row's `realm` column holds the tenant key**, not the authentication realm.\n- **`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.\n\n## WebAuthn and passkeys\n\n**The deployable `iam.svc` does not enable WebAuthn.** All four routes return\n`503 webauthn_disabled` as the first statement in the handler — before the authentication\ncheck, before body parsing, before any hook — and there is no configuration path that turns\nthem on. The relying-party identity (RP ID, display name, accepted origins) is accepted only\nthrough a Go option at handler construction, which the shipped service never calls, so\nenabling it means building a variant of the service.\n\n| Method | Path | Auth | Response in the shipped service |\n|---|---|---|---|\n| `POST` | `/auth/webauthn/register/begin` | authenticated | `503 webauthn_disabled` |\n| `POST` | `/auth/webauthn/register/finish` | authenticated | `503 webauthn_disabled` |\n| `POST` | `/auth/webauthn/assert/begin` | public | `503 webauthn_disabled` |\n| `POST` | `/auth/webauthn/assert/finish` | public | `503 webauthn_disabled` |\n\nNone of the four is rate-limited.\n\nThe rest of this section describes the flow as implemented, for anyone reading the credential\nschema or building against a variant of the service that wires it.\n\n### Registration\n\n`register/begin` takes `{\"name\": \"<label for the authenticator>\"}` from an authenticated\ncaller, loads that user's existing credentials, starts the ceremony, and persists the\nchallenge in `webauthn_session` under the name `register:<name>`:\n\n```json\n{ \"session_id\": \"01J…\", \"options\": { \"publicKey\": { \"challenge\": \"…\", \"rp\": { \"id\": \"…\" } } } }\n```\n\n`register/finish` takes `{\"session_id\", \"response\"}` — `response` being the raw authenticator\nattestation JSON — validates it against the stored challenge, writes a `webauthn_credential`\nrow (`userid`, `name` stripped of the `register:` prefix, `credential_id`, the serialized\ncredential, and the tenant key in the `realm` column), and deletes the session row. The\nresponse body is `{\"ok\": true}`; the credential id is **not** returned, despite what the\nhandler's own comment says. It is carried on the `after_webauthn_register` event as\nbase64url.\n\n### Assertion\n\n`assert/begin` takes `{\"email\"}`, resolves the user, loads their credentials, and stores the\nchallenge under `assert:<userid>`. It returns the same `{session_id, options}` envelope. An\nemail that matches no user and a user with no registered credentials both return\n`401 no_credentials`.\n\n`assert/finish` takes `{\"session_id\", \"response\"}`, rejects a session id whose stored name is\nnot an `assert:` session, validates the assertion, deletes the session row, emits\n`after_webauthn_assert`, runs the MFA gate, and mints. The success and MFA-required bodies\nare identical to the magic-link ones. A failed assertion emits `after_login_failure` with\nreason `webauthn_invalid_credential`. In managed mode the session records AAL 2 and auth\nmethod `webauthn`.\n\n| Status | Code | Cause |\n|---|---|---|\n| 503 | `webauthn_disabled` | Relying-party config absent — the shipped service's answer on every route |\n| 401 | `auth_required` | A register route called with no credential |\n| 400 | `missing_fields` | `name`, `email`, or `session_id` absent |\n| 400 | `no_tenant` | CEPT headers absent on an assert route |\n| 403 | `hook_denied` | A `before_webauthn_register` or `before_webauthn_assert` hook aborted |\n| 401 | `invalid_session` | Ceremony session not found, or an assert-finish presented a register session |\n| 401 | `no_credentials` | No credentials for this account, or the email matches no user |\n| 400 | `invalid_response` | Authenticator response body could not be parsed |\n| 401 | `webauthn_finish_failed` | Attestation or assertion verification failed |\n| 500 | `engine_unavailable` / `user_load_failed` / `webauthn_begin_failed` / `session_persist_failed` / `persist_failed` | Datastore or ceremony-library failure |\n\n### Limits of the WebAuthn flow\n\n- **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.\n- **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.\n- **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.\n- **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.\n- **Realm-blind**, like magic link: always the base `users` entity in the default realm. The `realm` column on `webauthn_credential` holds the tenant key.\n- **Not usable as a second factor.** The MFA gate has exactly one method, TOTP; a registered passkey does not satisfy it.\n\n## Continue with\n\n- [Password login](/blocks/baas/iam/authn/password/) — identifier columns, the realm gate and the login response\n- [Multi-factor authentication](/blocks/baas/iam/authn/mfa/) — the TOTP enrolment and the second step\n- [Tokens](/blocks/baas/iam/tokens/) — the claim set, lifetimes and refresh rotation\n- [Operating the block](/blocks/baas/iam/operating/configuration/) — `notify.url` and the rest of the service config\n- [IAM API reference](/blocks/baas/iam/api/) — every endpoint, request and response\n- [Error reference](/blocks/baas/iam/error/) — the full `(status, code)` table"
}