Scenarios 1–58 below are the usage flows of iam-api-list.md §2 — numbered and titled to match it, so §2.14 in the doc is 14 here — and 59 is the platform bootstrap from iam-operational.md §1. Requests are sent directly from your browser to the Kong gateway (http://localhost:8080) — no app-side proxy — so you are exercising the real gateway↔IAM orchestration: klp-auth-guard verifies the access token, forward-auths to IAM /internal/validate, mints the x-gateway-context, and proxies to the IAM service. Because this is a cross-origin call, Kong must allow CORS from http://localhost:4300 (incl. credentials and the OPTIONS preflight) or the browser will block the response.
How to use
Make sure Kong (:8080) and IAM (:3100) are up and the routes are loaded.
Empty database? There is no operator yet and none can be created through the API — start at 59 Creating the genesis operator (Genesis, the last menu item) to seed the first operator out of band and bring it online. Everything under Operator Auth needs that account to exist first.
Open a scenario with a login step — 2 / 3 / 9 / 10 / 14 (tenant) or 39 (operator) — and run it; the response's accessToken is captured automatically into the top Authorize bar.
Protected/admin endpoints then send Authorization: Bearer automatically; the gateway turns that token into the verified context the IAM service consumes. Public endpoints (🌐) send no Bearer token — there is no token input on them.
Fill path/query params and JSON bodies Swagger-style, then Send.
Every card carries a Field guide (what each input means and where its value comes from) and a Response guide (every property that comes back and what each value implies) — open them instead of guessing.
Seeded smoke user: smoke.user@example.com / Password123! (admin in tenant A). Out-of-band values (email verify links and short codes, password resets, invitations, OTP/TOTP codes, login-challenge codes, step-up codes, credential-change codes, device and registration tokens) are never returned by the API — use the ⛓ out-of-band helper shown on the card that consumes them to mint one locally (dev only), then copy the value into the form. Cards that take an Idempotency-Key show a header input: pre-filled with a fresh UUID where the route requires one, empty (no header sent) where it is optional.
Four things to know before the sign-up and sign-in groups
Each front door opens with the scenario that renders it.1 asks which sign-up methods are live, 7 which sign-in methods are. They are separate policies — one governs how an account may be created, the other how an account that already exists may authenticate — written from two separate operator scenarios (45 / 46), and they may disagree. A sign-in screen rendered from the registration policy will offer a method login refuses, or hide one it accepts.
Login takes an identifier — an email or an E.164 phone, discriminated by shape (scenarios 9 / 10). { email } still works as a deprecated alias, so nothing breaks. On a browser IAM already recognises the body needs only { password }: the device cookie names the account, which is what lets the “Welcome back” screen (8) avoid asking for an identifier it can only ever show masked.
A login may be a chain.POST /v1/auth/login can answer with a challenge instead of tokens, and each hop is cleared at /v1/auth/login/challenge/verify until the plan is exhausted (scenarios 11–13). The whole chain sits behind DEVICE_TRUST_ENABLED; with it off (the default) login behaves exactly as it always did and only 9 / 12 apply.
Link and short code are two halves of one challenge. Every out-of-band message (email verification, invitation) carries a long link and a 6-digit code on the same row — redeem either, and the other dies with it.
The single-hop /v1/auth/mfa/verify and /v1/auth/mfa/otp/request routes, and the one-shot /v1/auth/phone/register, are deprecated but fully working — rewired onto the same engine. They are shown where they still apply so existing clients can read their own flow, and mapped together with the retired routes in 37.
A 200 from login is a body to read
POST /v1/auth/login answers 200 five different ways, and only one of them is a sign-in. Branch on the body — accessToken, challengeToken, mfaRequired, noOrganization, invitationPending — before assuming the caller is signed in.
Belonging nowhere is not a 403 any more (15). A complete account with no membership of any kind — typically a member removed from their only organization — gets { noOrganization, bootstrapToken }: no session, no cookie, no access token, and a token that opens exactly one door, POST /v1/auth/organizations/bootstrap, which founds their organization and signs them into it. The signed-in counterpart is 24, where GET /v1/me → foundedTenantId says whether founding is even offerable: the cap is one live founded organization per identity.
An invitation you have not accepted is not an organization (51). When the only seats are invitations, login answers { invitationPending, acceptToken } instead — and that token is the proof an existing identity must present to POST /v1/auth/invitations/accept. The two tokens are not interchangeable. The login card shows either one in its capture line.
Every profile change starts with a step-up
§153 split profile editing into one route per field, and every one of them spends a single-use step-up token minted for one named action. A live session is never enough. The old combined routes — PATCH /v1/me, POST /v1/auth/password/change, POST /v1/me/phone and its OTP request, POST /v1/me/mfa/otp/request — now answer 410, and 37 maps each to its successor.
🔐 The tester carries the token for you.POST /v1/me/step-up proves the current password and answers a challengeId and a password-assurance token; both are captured and filled into the cards below it. …/code sends a code bound to that challenge, …/verify proves a factor and returns a strong token that replaces the first, and the card that makes the change spends whichever is newest. Mint a new one per action — a token for mfa_disable will not regenerate recovery codes.
Password assurance is enough for some actions, never for others. A display name (28) and a password change (35) take the password token as-is. Changing the sign-in channel (33) and both MFA actions (27) need a strong one. The response's passwordOnlyAllowed says which case you are in — and an account with no eligible factor is handed an empty options list, which means password-only is the only route and the change will be held.
A contact change is a row, and a password-only one is held (29–32). A code goes to the new address or number while the current one keeps signing in. A strong token applies the change when its code is confirmed; a password-only one is held three days, and every notice carries a cancel link that works without a session — because the email is where password recovery goes, and a stolen session must not be able to point it elsewhere for good. 36 is the view across all live changes, and the way to stop them signed in or signed out.
Each add is paired with its change. Linking a first email (29) and changing one you hold (30) are the same route, as are linking a first phone (31) and replacing it (32) — what routes you is email: null / phone: null on GET /v1/me. The differences all follow from whether there is an old value to protect: no old address means no current value factor to prove, no 422 unchanged to hit, and the notice goes to the other channel instead.
Two new ⛓ helpers. “Get a step-up code” reads the code bound to the open challenge (it is pre-filled with the captured challengeId), and “Get a credential-change code” reads the code of your live email / phone / password change (pre-filled with your userId). The sessionless cancel link has no helper — its token exists only in the notice.
One session, many apps
Seven scenarios — 17, 18, 20, 47, 48, 56 and 57 — cover the multi-app surface. In the first six, every route sits behind MULTI_APP_ENABLED and answers 404 when it is off; they carry a ⚑ badge in the menu so a 404 there reads as the flag rather than a wrong path. 57 carries none, because the doc gates its two join routes behind no flag.
Three separate acts, deliberately. Registering an app (platform configuration, one row serving every tenant) is not enabling a tenant for it (access control, per tenant) — both live in 47 — and neither grants any individual anything, which is 56. Roles say what a member may do inside an app; grants say which apps they may enter at all.
…and one act the member performs themselves (57). An app the operator marked is_auto_enabled_on_register is free to acquire at sign-up — a visitor with no account gets a tenant, an enablement and a grant in one transaction just by filling in the registration page. Someone who already has an account, opening that same page, is refused 409 …_already_registered, and had no other route: 57 is that route, and gates on the same registry predicates the registration hand-off uses, refusals included. It is the only app-surface scenario with no ⚑ badge — the doc puts /v1/me/app-join-targets and /v1/me/app-joins behind no flag; only the drop-down check at its last step is behind MULTI_APP_ENABLED. It is not the paid subscription path: an app the operator has not opted in is refused, and reaching it means subscribing.
A join is not finished until you switch (57). A session carries one tenant. Join into organization B while signed in as A, redirect without switching, and the app receives a token scoped to A — which has no enablement for it — so the caller meets the very refusal they came to escape, having just successfully joined. That is why switch-tenant has its own card in 57 rather than a footnote, and why the join response returns a tenantId even for an organization it has just founded.
A granted member who is missing downstream is now an anomaly. Granting (56) projects the member at the request — user.upserted + membership.upserted in the same transaction, and a revoke emits membership.removed for that app alone. That closed the gap in which a member granted after their tenant's backfill passed every /internal/validate hop while being absent from the app's own copy. So if one is missing now, suspect an undrained outbox or a failed delivery; the diagnosis and the re-sync are 48. (The doc's §3.96 / §3.97 still describe the old gap; §2.47 and the service agree it is closed, and the cards say so.)
Model A and Model B sit side by side.16 is the older shared-login probe (OAUTH_ENABLED): the app asks IAM to validate a return URL and IAM renders the login. 17 supersedes it — a standards OAuth 2.1 authorization request returning the app's own app-scoped tokens. Different flags, rolled out independently.
Authorize is not a fetch call.GET /v1/oauth/authorize answers 302 and carries no CORS headers under any circumstances, because OAuth 2.1 reaches it by navigation. Its card opens the URL in a new tab and takes the redirect back by paste. The 🔑 PKCE control mints the verifier/challenge pair and fills the verifier into the token exchange, because a mismatch there answers a deliberately undifferentiated 400 invalid_grant.
N chains, one session.19 renews the session; 20 is what lets two products stay open in two tabs without either signing the other out. Each app rotates on its own /v1/auth/apps/{clientId}/refresh route — and /v1/auth/refresh answers 404 wherever the flag is on. The chains are isolated; the session is not. Switching tenant (21) repoints every live chain at once, and revoking the session (22) kills all of them — there is no per-app logout.
The two CORS preflights have no cards, on purpose.OPTIONS /v1/oauth/token (§3.90a) and OPTIONS /v1/auth/apps/{clientId}/refresh (§3.92a) are issued by the browser, never by application code, so every Send on the token and app-refresh cards already exercises them — and a hand-rolled fetch of one could only ever be blocked. They differ in granularity because of what a preflight can identify: the refresh route names the client in the path, so its allow-list is genuinely per-client and refuses another registered client's origin; the token route carries the client id in the body, so its allow-list is registry-wide and the per-client binding is enforced by the code binding and PKCE instead.
One invitation, five scenarios
50–54 follow one invitation in the order it actually travels: sent by email (50) or by phone (51), accepted from the message by someone who already has an account (52) or from inside a live session (53), and re-delivered when it never arrived (54). Each of the last three exists because a plausible-looking shortcut is wrong.
Ask, then prove, before you accept (52). One identifier is one identity across every tenant, so POST /v1/invitationsreuses an existing account rather than duplicating it — and the invitee therefore arrives at the accept screen in one of two entirely different situations. POST /v1/auth/invitations/describe answers which, without consuming the invitation, so password is sent only when the account has no credential yet. An existing identity must then sign in first and present that proof — the acceptToken from an invitationPending login, or its own session — or accept answers 401 invitation_requires_signin. A tenant-scoped invitation used to act as a global password reset for any address an admin could name; now it writes nothing global at all.
Signed in? Accept from the list (53). An invitation shows up in GET /v1/me/memberships as a status: invited row. Render it as “accept”, never “switch” — switching to an invited seat is 403. POST /v1/me/memberships/{tenantId}/accept takes no token: the gateway context is the identity, and the row is the evidence of the invite.
Resend, don't re-invite (54).POST /v1/invitations answers 409 while an open invitation exists — even a lapsed one, because the open-invite index is partial on used_at IS NULL and cannot consider expiry. POST /v1/invitations/{userId}/resend is the only path that helps during the link's 7 days: it consumes the open row (both old secrets die) and mints a fresh link + code on the invitation's original channel. Mint the ⛓ helper value after the resend — anything copied before it is already dead.
How the scenarios are grouped
Tenant Auth (1–38) — the public, tenant-facing profile: signing up, signing in, sessions & devices, organizations, MFA, profile & credentials, legacy clients, and the platform discovery surface. Sub-headings follow the doc's own purpose clusters, so counterparts sit adjacent: signing in with no organization (15) and founding one while signed in (24), enrol-MFA next to disable-MFA, each add next to its change (29/30, 31/32), sessions next to devices, one app (19) next to many (20). The doc's 2026-09-27 revision folded its old passwords cluster into profile & credentials, so the reset (34) and the signed-in change (35) sit with the other credential flows.
Operator Auth (39–49) — the internal control plane: operator sign-in (MFA mandatory, no device trust), operator & role administration, tenant lifecycle, act-as, the cross-tenant audit read, the two global policy writes — registration (45) and login (46), separate levers on purpose, since retiring a channel usually means editing both — the app registry (47) next to diagnosing an app that is missing a member (48), and internal ops.
Both (50–58) — tenant-admin flows an operator also reaches via act-as. The invitation half is five scenarios (50–54, above), then member/role administration (55). The app half splits the same dimension two ways — an admin deciding which apps a member may enter (56) and a member acquiring a free-to-acquire app for an organization they administer (57) — and the tenant audit log (58) records both.
Genesis (59) — platform bootstrap. Outside §2 (it is iam-operational.md §1) and deliberately last, because you only ever run it once per platform.