{
  "docId": "baas.iam.authn.mfa",
  "title": "Multi-factor authentication",
  "summary": "TOTP enrolment in iam.svc, the MFA gate that returns a pending challenge instead of tokens, and how the second factor is verified.",
  "url": "https://docs.kis.ai/blocks/baas/iam/authn/mfa/",
  "markdown": "https://docs.kis.ai/blocks/baas/iam/authn/mfa.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": "Routes",
      "id": "routes"
    },
    {
      "depth": 2,
      "text": "Enrolment",
      "id": "enrolment"
    },
    {
      "depth": 3,
      "text": "Start",
      "id": "start"
    },
    {
      "depth": 3,
      "text": "Confirm",
      "id": "confirm"
    },
    {
      "depth": 3,
      "text": "Remove",
      "id": "remove"
    },
    {
      "depth": 2,
      "text": "The MFA gate",
      "id": "the-mfa-gate"
    },
    {
      "depth": 3,
      "text": "Flows the gate covers",
      "id": "flows-the-gate-covers"
    },
    {
      "depth": 3,
      "text": "Flows the gate does not cover",
      "id": "flows-the-gate-does-not-cover"
    },
    {
      "depth": 2,
      "text": "The pending token",
      "id": "the-pending-token"
    },
    {
      "depth": 2,
      "text": "Completing the challenge",
      "id": "completing-the-challenge"
    },
    {
      "depth": 2,
      "text": "Codes, steps and replay",
      "id": "codes-steps-and-replay"
    },
    {
      "depth": 2,
      "text": "Errors",
      "id": "errors"
    },
    {
      "depth": 2,
      "text": "Events",
      "id": "events"
    },
    {
      "depth": 2,
      "text": "Not implemented",
      "id": "not-implemented"
    }
  ],
  "wordCount": 2092,
  "body": "`iam.svc` implements exactly one second factor: TOTP (SHA-1, 6 digits, 30-second step).\nThe `methods` array in every challenge response is the literal `[\"totp\"]` — there is no\nSMS or email OTP, no recovery or backup codes, and no passkey-as-second-factor. The\nmethod list is fixed in the binary; no configuration key adds to it.\n\nMFA is per user and opt-in. A user is challenged because they hold an active TOTP\nenrolment, and nothing in the product's `iam/` files or the tenant's configuration can\nrequire one. There is no tenant-wide, realm-wide or role-wide \"MFA required\" policy.\n\n## Routes\n\n| Method | Path | Auth | Rate limit | Purpose |\n|--------|------|------|-----------|---------|\n| `POST` | `/auth/mfa/totp/setup` | Bearer | none | Generate a pending TOTP secret |\n| `POST` | `/auth/mfa/totp/enable` | Bearer | none | Confirm the pending secret with a live code |\n| `POST` | `/auth/mfa/totp/disable` | Bearer | none | Remove the enrolment (requires a live code) |\n| `POST` | `/auth/mfa/verify` | the pending token | `auth_mfa` | Complete a login's second step |\n\nRoutes carry no `/v2` prefix. All four require the four tenancy headers\n(`X-Customer`, `X-Product`, `X-Env`, `X-Tenant`); without them the response is\n`400 no_tenant`. Full request and response shapes are in the\n[IAM API reference](/blocks/baas/iam/api/).\n\n## Enrolment\n\n### Start\n\n`POST /auth/mfa/totp/setup` needs an authenticated caller and no body (an empty object\nis fine). It generates a fresh 16-byte (128-bit) base32 secret and stores it as a\n**pending** enrolment.\n\n```json\n{\n  \"secret\": \"JBSWY3DPEHPK3PXP…\",\n  \"otpauth_url\": \"otpauth://totp/<issuer>%3AU01K…?algorithm=SHA1&digits=6&issuer=<issuer>&period=30&secret=JBSWY3DPEHPK3PXP…\"\n}\n```\n\nThe URI parameters are fixed:\n\n| Parameter | Value |\n|-----------|-------|\n| `algorithm` | `SHA1` |\n| `digits` | `6` |\n| `period` | `30` |\n| `issuer` | a single constant compiled into the service |\n| label | `<issuer>:<subject id>` |\n\nRe-running setup before enable rotates the pending secret. Re-running it after enable\nreturns `409 already_enabled` — disable first to re-enrol.\n\n:::caution\nThe `issuer` label is one service-wide constant. It is not derived from the tenant, the\nproduct, or the realm, so every user of every tenant sees the same account name in their\nauthenticator app. Show your own product name alongside the QR code rather than relying\non the label.\n:::\n\n:::note\nThe enrolment row is keyed on the **authenticated principal's subject**. A request\nauthenticated with `Authorization: ApiKey` sets that subject to the API-key row id, not\nthe user, so enrolment must be driven with a user access token. Build your enrolment\nscreen behind a Bearer credential.\n:::\n\n### Confirm\n\n`POST /auth/mfa/totp/enable` with `{\"code\": \"123456\"}` verifies the code against the\npending secret and marks the enrolment active. From that moment every gated flow\nchallenges the user.\n\nEnable deliberately does **not** record the consumed time step, so a login inside the\nsame 30-second window is not rejected as a replay.\n\nErrors: `400 no_pending_setup` when nothing is pending, `409 already_enabled` when it is\nalready active, `401 invalid_code` when the code does not verify.\n\n### Remove\n\n`POST /auth/mfa/totp/disable` with `{\"code\": \"123456\"}` deletes the enrolment. A valid\ncurrent code is required — proving possession of the second factor means a hijacked\nsession alone cannot strip MFA. When nothing is enrolled the call is an idempotent\n`200 {\"ok\": true}`.\n\n:::caution\nThere is no recovery path for a lost authenticator. Disable is the only route that\nremoves an enrolment and it demands a live code; no operator or admin endpoint clears one.\nThe `user_mfa_request` entity restricts read, create, update and delete to the service's\nown identity, so the external `/rest` and `/admin` surfaces are denied it — a tenant admin\ncannot clear an enrolment through the generic entity API either.\n:::\n\n:::caution\nThe TOTP shared secret is declared `compliances: encrypted`, but the field-encryption\nhooks are installed **only when a vault client is registered** for the deployment. On a\nkeyless or dev boot the hooks are skipped and the secret is stored in plaintext. See\n[operating the block](/blocks/baas/iam/operating/configuration/).\n:::\n\n## The MFA gate\n\nAfter a primary factor succeeds, the flow looks for an active TOTP enrolment for the\nauthenticated user. If one exists it returns `200` with a **challenge instead of\ncredentials** — no access token, no refresh token, no session reference.\n\n```json\n{\n  \"mfa_required\": true,\n  \"mfa_token\": \"v4.public.…\",\n  \"methods\": [\"totp\"]\n}\n```\n\nThe status code is `200`, not `401`. Branch your client on the `mfa_required` field.\n\nEnrolment is detected by a single lookup on `(user id, provider = \"totp\")` with the\nactive flag set. Rows carrying any other provider value are invisible to the gate.\n\n### Flows the gate covers\n\n| Flow | Route | Shipped behaviour |\n|------|-------|------------------|\n| Password login | `POST /auth/login` | Gate fires; the pending token carries the login realm |\n| Magic-link verify | `POST /auth/magic/verify` | Gate fires; the pending token carries the realm `users` |\n| Passkey assertion | `POST /auth/webauthn/assert/finish` | Gate is in the handler, but the route answers `503 webauthn_disabled` in the shipping binary |\n| Federated callback | `GET /auth/oauth/:provider/callback` | Gate is in the handler, but the route answers `404 unknown_provider` in the shipping binary |\n\nThe deployable service wires neither a WebAuthn relying party nor an OAuth provider, and\nthere is no configuration path that does. In practice the gate is reachable on password\nlogin and magic-link verify. See [password login](/blocks/baas/iam/authn/password/) and\n[passwordless login](/blocks/baas/iam/authn/passwordless/) for what those two routes\nreturn when the user holds no enrolment.\n\n### Flows the gate does not cover\n\n| Flow | Route |\n|------|-------|\n| Control-plane operator login | `POST /auth/login` with `realm: \"superadmin\"` |\n| Access-token refresh | `POST /auth/refresh` |\n| First-superadmin bootstrap | `POST /auth/bootstrap` |\n| API-key authentication | any route accepting `Authorization: ApiKey` |\n| Service-token exchange | `POST /auth/token` |\n| Managed-session exchange | `POST /internal/session/exchange` |\n| Operator impersonation | `POST /superadmin/impersonate` |\n\nControl-plane login carries no MFA at all: the plane holds no enrolment storage, and that\npath also skips realm resolution and the provider gate. See\n[superadmin and impersonation](/blocks/baas/iam/operating/configuration/) for what does guard it.\n\n:::caution\nFor federated login the second factor is enforced by this service's own callback handler\nagainst a local TOTP enrolment. Nothing is asserted to or required from the external\nidentity provider — no `acr` or `amr` claim is read and no `id_token` is validated. An IdP\nthat performed its own MFA does not satisfy this gate, and an IdP that performed none is\nnot detected.\n:::\n\n## The pending token\n\nThe `mfa_token` is a PASETO v4.public signed with the same per-tenant Ed25519 key as\naccess tokens. It carries only:\n\n| Claim | Value |\n|-------|-------|\n| `iss` | `iam` |\n| `sub` | the identity's row id |\n| `tenant` | the tenancy key the login was made against |\n| `realm` | the realm the identity was authenticated in |\n| `type` | `mfa_pending` |\n| `iat` / `nbf` / `exp` | issued-at, not-before, and expiry |\n\nIt lives **5 minutes** and carries no roles and no custom claims.\n\nThe auth middleware rejects `type: mfa_pending` explicitly on every protected route with\n`401 invalid_token`, after the signature, tenant-pin and temporal checks have already\npassed. `POST /auth/mfa/verify` is the only endpoint that accepts it. Token formats,\nclaims and verification are covered under [tokens](/blocks/baas/iam/tokens/).\n\n:::note\nPassword login stamps the realm the user actually logged into, so verify re-reads the\nidentity from that realm's entity. Magic link, passkey assertion and the federated\ncallback stamp the literal realm name `users` — not the tenant's configured default realm\n— because they authenticate against the base `users` entity regardless of realm\nconfiguration. If a layer has deactivated the `users` realm, verify after one of those\nflows fails with `401 unknown_realm`.\n:::\n\n## Completing the challenge\n\n`POST /auth/mfa/verify` is public — the pending token is the credential.\n\n```json\n{ \"mfa_token\": \"v4.public.…\", \"code\": \"123456\" }\n```\n\nChecks run in order, each with its own refusal:\n\n1. Signature against the tenant's keyring.\n2. The `tenant` claim equals the request's tenancy key.\n3. The token is inside its validity window.\n4. `type` is `mfa_pending`.\n5. The subject still holds an active TOTP enrolment.\n6. The code verifies against the stored secret.\n7. The code's 30-second step has not already been consumed.\n8. The realm named on the token still resolves.\n\nOn success the consumed step is recorded and the flow mints exactly what a\nsingle-factor login would have returned:\n\n```json\n{\n  \"token\": \"v4.public.…\",\n  \"refresh_token\": \"01J…ULID.<secret>\",\n  \"expires_in\": 900,\n  \"user\": { \"id\": \"U01K…\", \"email\": \"user@example.com\", \"displayname\": \"…\" }\n}\n```\n\nOn a tenant using managed sessions the response is instead `{session_id, expires_at, user}`\nwith the `kis_session` cookie, and the session row records assurance level 2 and auth\nmethod `totp`. Login short-circuits to the challenge *before* its own session branch,\nwhich is why the completed second step is what creates the session — see\n[sessions](/blocks/baas/iam/tokens/sessions/).\n\n`POST /auth/mfa/verify` is rate-limited under the `auth_mfa` profile: a per-(tenant, IP)\ntoken bucket of capacity 30, refilling 0.5 per second. The three enrolment routes are not\nrate-limited.\n\n## Codes, steps and replay\n\nThe verifier accepts **only the current 30-second step**. There is no skew window, so a\ndevice whose clock has drifted more than a few seconds fails with `401 invalid_code`.\n\nA TOTP code stays valid for its whole step, which makes an observed code replayable\ninside that window. Verify records the consumed step on the enrolment and refuses a\nsecond use of the same step with `401 code_reused` (\"this code was already used; wait for\nthe next one\"). Enable and disable verify the code but do not record the step.\n\n## Errors\n\n| Code | HTTP | Where | Meaning |\n|------|------|-------|---------|\n| `auth_required` | 401 | setup, enable, disable | No authenticated principal on the request |\n| `no_tenant` | 400 | all | Tenancy headers missing |\n| `missing_fields` | 400 | enable, verify | `code`, or `mfa_token` and `code`, absent |\n| `no_pending_setup` | 400 | enable | No pending enrolment — call setup first |\n| `already_enabled` | 409 | setup, enable | TOTP is already active for this subject |\n| `invalid_code` | 401 | enable, disable, verify | The code did not verify for the current step |\n| `code_reused` | 401 | verify | That step was already consumed |\n| `invalid_mfa_token` | 401 | verify | Bad signature, another tenant, outside the 5-minute window, or `type` is not `mfa_pending` |\n| `mfa_not_enabled` | 401 | verify | The token's subject holds no active enrolment |\n| `unknown_realm` | 401 | verify | The realm on the pending token no longer resolves |\n| `invalid_token` | 401 | protected routes | An `mfa_pending` token was presented outside verify |\n| `rate_limited` | 429 | verify | The `auth_mfa` bucket is empty |\n| `lookup_failed` | 500 | setup | Could not read the existing enrolment |\n| `mfa_lookup_failed` | 500 | gated flows | Could not decide whether MFA is required |\n| `persist_failed` | 500 | setup, enable | Could not write the enrolment row |\n| `engine_unavailable` | 500 | all | The tenant datastore could not be reached |\n| `user_lookup_failed` | 500 | verify | The identity row could not be loaded |\n| `sign_failed` | 500 | gated flows, verify | Token signing failed |\n| `issue_failed` | 500 | verify | The refresh-token row could not be persisted |\n\n`disable` never returns `missing_fields`: a body it cannot decode is treated as an empty\ncode, which then fails the code check. The block-wide table is on the\n[error reference](/blocks/baas/iam/error/).\n\n## Events\n\nThree auth-flow events fire on this surface: `after_mfa_enable`, `after_mfa_disable`\n(emitted on both the success and the wrong-code path) and `after_mfa_verify` (emitted on\nsuccess, on a wrong code, and on a reused code). Each payload carries `user_id` and\n`method: \"totp\"`; failures add a `reason`. A successful verify also emits\n`after_login_success` with `flow: \"mfa_verify\"`.\n\n:::note\n`before_mfa_enable`, `before_mfa_disable` and `before_mfa_verify` are declared as event\nnames but no handler emits them. A hook registered on one of those never runs.\n:::\n\n## Not implemented\n\nNone of the following exists in the shipping binary, and no configuration enables any of\nthem:\n\n- A second method of any kind — SMS OTP, email OTP, or a passkey used as a second factor.\n- Recovery codes, backup codes, or trusted-device / \"remember this browser\" bypass.\n- A tenant, realm, or role policy that requires enrolment.\n- An operator or admin endpoint that clears a lost enrolment.\n- Step-up re-authentication on a live session. The assurance level is recorded on managed\n  session rows but is not a token claim, so no route can require it from a token.\n- MFA on the control-plane operator login."
}