Skip to content
Talk to our solutions team

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.

MethodPathAuthRate-limit profile
POST/auth/magic/requestpublicauth_magic
POST/auth/magic/verifythe code is the credentialauth_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.

POST /auth/magic/request
Content-Type: application/json
X-Customer: acme
X-Product: iam
X-Env: prod
X-Tenant: main
{ "email": "[email protected]" }
{ "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.

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 is a service-level concern, not a per-product one. The sink is wired from the notify.url service config key:

notify.urlBehaviour
setPOST {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
unsetCodes 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_email": "[email protected]",
"user_firstname": "",
"user_lastname": "",
"user_displayname": "",
"expires_in_minutes": 15
},
"provider_context": { "to": "[email protected]", "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.

POST /auth/magic/verify
{ "email": "[email protected]", "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:

{
"token": "v4.public.…",
"refresh_token": "01J…ULID.<base64url-secret>",
"expires_in": 900,
"user": { "id": "U01K…", "email": "[email protected]", "displayname": "" }
}

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".

StatusCodeCause
400bad_requestBody is not valid JSON
400missing_fieldsemail or code absent
400no_tenantCPET headers absent
401invalid_magicUnknown email, no matching code hash, expired, or already consumed — one envelope for all four
401invalid_tokenThe identity row disappeared between verify and mint
500engine_unavailableTenant datastore unreachable
500delete_failedCould not consume the row
500mfa_lookup_failedCould not read the TOTP enrolment
500sign_failed / issue_failedToken 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.

  • 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.

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.

MethodPathAuthResponse in the shipped service
POST/auth/webauthn/register/beginauthenticated503 webauthn_disabled
POST/auth/webauthn/register/finishauthenticated503 webauthn_disabled
POST/auth/webauthn/assert/beginpublic503 webauthn_disabled
POST/auth/webauthn/assert/finishpublic503 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.

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.

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.

StatusCodeCause
503webauthn_disabledRelying-party config absent — the shipped service’s answer on every route
401auth_requiredA register route called with no credential
400missing_fieldsname, email, or session_id absent
400no_tenantCPET headers absent on an assert route
403hook_deniedA before_webauthn_register or before_webauthn_assert hook aborted
401invalid_sessionCeremony session not found, or an assert-finish presented a register session
401no_credentialsNo credentials for this account, or the email matches no user
400invalid_responseAuthenticator response body could not be parsed
401webauthn_finish_failedAttestation or assertion verification failed
500engine_unavailable / user_load_failed / webauthn_begin_failed / session_persist_failed / persist_failedDatastore or ceremony-library failure
  • 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.