Magic link, WebAuthn and passkeys
iam.svc ships two passwordless code paths. Magic link works end to end: two public
routes, a one-time code delivered through notify.svc, 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.
Magic link
Section titled “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 CPET 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
Section titled “Request a code”POST /auth/magic/requestContent-Type: application/jsonX-Customer: acmeX-Product: iamX-Env: prodX-Tenant: main{ "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:
- Generates 32 random bytes, base64url-encoded — the plaintext code.
- Writes a
user_magic_linkrow holdinghex(SHA-256(code))intoken,provider"email",approvedfalse, andexpiresat= now + 15 minutes. - Hands the plaintext to the notification sink with purpose
magic_linkand channelemail. - Emits
after_magic_requestcarryingsentand, on delivery failure,sink_error.
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 CPET 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
Section titled “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 CPET 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:
{ "tenantkey": "acme:iam:prod:main", "templatename": "magic_link", "data": { "code": "<plaintext code>", "user_id": "U01K…", "user_firstname": "…", "user_lastname": "…", "user_displayname": "…", "expires_in_minutes": 15 }, "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.
Verify the code
Section titled “Verify the code”POST /auth/magic/verifyBoth 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:
{ "token": "v4.public.…", "refresh_token": "01J…ULID.<base64url-secret>", "expires_in": 900,}A user with TOTP enrolled gets the second-step response instead — a magic link does not bypass a second factor:
{ "mfa_required": true, "mfa_token": "v4.public.…", "methods": ["totp"] }Complete it at POST /auth/mfa/verify; see Multi-factor authentication.
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.
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 | CPET 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.
Limits of the magic-link flow
Section titled “Limits of the magic-link flow”- Email only. The request body accepts one field,
email; the storedprovideris 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_otptable exists in the schema and is read by no code. - Realm-blind. The flow hard-codes the default realm
usersand queries the baseusersentity. Listing or omitting amagicprovider in a realm’sproviders: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
approvedflag is writtenfalseand never read, and the entity’snumberscolumn is neither written nor read. Verification matches on the code hash and expiry alone. - The row’s
realmcolumn holds the tenant key, not the authentication realm. before_magic_verifyis a declared event that nothing emits. A hook registered on it never fires; gate the flow atbefore_magic_requestinstead.
WebAuthn and passkeys
Section titled “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
Section titled “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>:
{ "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
Section titled “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 | CPET 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
Section titled “Limits of the WebAuthn flow”- No discoverable-credential / autofill login.
assert/beginrequires 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_sessionrows 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 afinishcall succeeds. - Realm-blind, like magic link: always the base
usersentity in the default realm. Therealmcolumn onwebauthn_credentialholds 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
Section titled “Continue with”- Password login — identifier columns, the realm gate and the login response
- Multi-factor authentication — the TOTP enrolment and the second step
- Tokens — the claim set, lifetimes and refresh rotation
- Operating the block —
notify.urland the rest of the service config - IAM API reference — every endpoint, request and response
- Error reference — the full
(status, code)table