Multi-factor authentication
iam.svc implements exactly one second factor: TOTP (SHA-1, 6 digits, 30-second step).
The methods array in every challenge response is the literal ["totp"] — there is no
SMS or email OTP, no recovery or backup codes, and no passkey-as-second-factor. The
method list is fixed in the binary; no configuration key adds to it.
MFA is per user and opt-in. A user is challenged because they hold an active TOTP
enrolment, and nothing in the product’s iam/ files or the tenant’s configuration can
require one. There is no tenant-wide, realm-wide or role-wide “MFA required” policy.
Routes
Section titled “Routes”| Method | Path | Auth | Rate limit | Purpose |
|---|---|---|---|---|
POST | /auth/mfa/totp/setup | Bearer | none | Generate a pending TOTP secret |
POST | /auth/mfa/totp/enable | Bearer | none | Confirm the pending secret with a live code |
POST | /auth/mfa/totp/disable | Bearer | none | Remove the enrolment (requires a live code) |
POST | /auth/mfa/verify | the pending token | auth_mfa | Complete a login’s second step |
Routes carry no /v2 prefix. All four require the four tenancy headers
(X-Customer, X-Product, X-Env, X-Tenant); without them the response is
400 no_tenant. Full request and response shapes are in the
IAM API reference.
Enrolment
Section titled “Enrolment”POST /auth/mfa/totp/setup needs an authenticated caller and no body (an empty object
is fine). It generates a fresh 16-byte (128-bit) base32 secret and stores it as a
pending enrolment.
{ "secret": "JBSWY3DPEHPK3PXP…", "otpauth_url": "otpauth://totp/<issuer>%3AU01K…?algorithm=SHA1&digits=6&issuer=<issuer>&period=30&secret=JBSWY3DPEHPK3PXP…"}The URI parameters are fixed:
| Parameter | Value |
|---|---|
algorithm | SHA1 |
digits | 6 |
period | 30 |
issuer | a single constant compiled into the service |
| label | <issuer>:<subject id> |
Re-running setup before enable rotates the pending secret. Re-running it after enable
returns 409 already_enabled — disable first to re-enrol.
Confirm
Section titled “Confirm”POST /auth/mfa/totp/enable with {"code": "123456"} verifies the code against the
pending secret and marks the enrolment active. From that moment every gated flow
challenges the user.
Enable deliberately does not record the consumed time step, so a login inside the same 30-second window is not rejected as a replay.
Errors: 400 no_pending_setup when nothing is pending, 409 already_enabled when it is
already active, 401 invalid_code when the code does not verify.
Remove
Section titled “Remove”POST /auth/mfa/totp/disable with {"code": "123456"} deletes the enrolment. A valid
current code is required — proving possession of the second factor means a hijacked
session alone cannot strip MFA. When nothing is enrolled the call is an idempotent
200 {"ok": true}.
The MFA gate
Section titled “The MFA gate”After a primary factor succeeds, the flow looks for an active TOTP enrolment for the
authenticated user. If one exists it returns 200 with a challenge instead of
credentials — no access token, no refresh token, no session reference.
{ "mfa_required": true, "mfa_token": "v4.public.…", "methods": ["totp"]}The status code is 200, not 401. Branch your client on the mfa_required field.
Enrolment is detected by a single lookup on (user id, provider = "totp") with the
active flag set. Rows carrying any other provider value are invisible to the gate.
Flows the gate covers
Section titled “Flows the gate covers”| Flow | Route | Shipped behaviour |
|---|---|---|
| Password login | POST /auth/login | Gate fires; the pending token carries the login realm |
| Magic-link verify | POST /auth/magic/verify | Gate fires; the pending token carries the realm users |
| Passkey assertion | POST /auth/webauthn/assert/finish | Gate is in the handler, but the route answers 503 webauthn_disabled in the shipping binary |
| Federated callback | GET /auth/oauth/:provider/callback | Gate is in the handler, but the route answers 404 unknown_provider in the shipping binary |
The deployable service wires neither a WebAuthn relying party nor an OAuth provider, and there is no configuration path that does. In practice the gate is reachable on password login and magic-link verify. See password login and passwordless login for what those two routes return when the user holds no enrolment.
Flows the gate does not cover
Section titled “Flows the gate does not cover”| Flow | Route |
|---|---|
| Control-plane operator login | POST /auth/login with realm: "superadmin" |
| Access-token refresh | POST /auth/refresh |
| First-superadmin bootstrap | POST /auth/bootstrap |
| API-key authentication | any route accepting Authorization: ApiKey |
| Service-token exchange | POST /auth/token |
| Managed-session exchange | POST /internal/session/exchange |
| Operator impersonation | POST /superadmin/impersonate |
Control-plane login carries no MFA at all: the plane holds no enrolment storage, and that path also skips realm resolution and the provider gate. See superadmin and impersonation for what does guard it.
The pending token
Section titled “The pending token”The mfa_token is a PASETO v4.public signed with the same per-tenant Ed25519 key as
access tokens. It carries only:
| Claim | Value |
|---|---|
iss | iam |
sub | the identity’s row id |
tenant | the tenancy key the login was made against |
realm | the realm the identity was authenticated in |
type | mfa_pending |
iat / nbf / exp | issued-at, not-before, and expiry |
It lives 5 minutes and carries no roles and no custom claims.
The auth middleware rejects type: mfa_pending explicitly on every protected route with
401 invalid_token, after the signature, tenant-pin and temporal checks have already
passed. POST /auth/mfa/verify is the only endpoint that accepts it. Token formats,
claims and verification are covered under tokens.
Completing the challenge
Section titled “Completing the challenge”POST /auth/mfa/verify is public — the pending token is the credential.
{ "mfa_token": "v4.public.…", "code": "123456" }Checks run in order, each with its own refusal:
- Signature against the tenant’s keyring.
- The
tenantclaim equals the request’s tenancy key. - The token is inside its validity window.
typeismfa_pending.- The subject still holds an active TOTP enrolment.
- The code verifies against the stored secret.
- The code’s 30-second step has not already been consumed.
- The realm named on the token still resolves.
On success the consumed step is recorded and the flow mints exactly what a single-factor login would have returned:
{ "token": "v4.public.…", "refresh_token": "01J…ULID.<secret>", "expires_in": 900,}On a tenant using managed sessions the response is instead {session_id, expires_at, user}
with the kis_session cookie, and the session row records assurance level 2 and auth
method totp. Login short-circuits to the challenge before its own session branch,
which is why the completed second step is what creates the session — see
sessions.
POST /auth/mfa/verify is rate-limited under the auth_mfa profile: a per-(tenant, IP)
token bucket of capacity 30, refilling 0.5 per second. The three enrolment routes are not
rate-limited.
Codes, steps and replay
Section titled “Codes, steps and replay”The verifier accepts only the current 30-second step. There is no skew window, so a
device whose clock has drifted more than a few seconds fails with 401 invalid_code.
A TOTP code stays valid for its whole step, which makes an observed code replayable
inside that window. Verify records the consumed step on the enrolment and refuses a
second use of the same step with 401 code_reused (“this code was already used; wait for
the next one”). Enable and disable verify the code but do not record the step.
Errors
Section titled “Errors”| Code | HTTP | Where | Meaning |
|---|---|---|---|
auth_required | 401 | setup, enable, disable | No authenticated principal on the request |
no_tenant | 400 | all | Tenancy headers missing |
missing_fields | 400 | enable, verify | code, or mfa_token and code, absent |
no_pending_setup | 400 | enable | No pending enrolment — call setup first |
already_enabled | 409 | setup, enable | TOTP is already active for this subject |
invalid_code | 401 | enable, disable, verify | The code did not verify for the current step |
code_reused | 401 | verify | That step was already consumed |
invalid_mfa_token | 401 | verify | Bad signature, another tenant, outside the 5-minute window, or type is not mfa_pending |
mfa_not_enabled | 401 | verify | The token’s subject holds no active enrolment |
unknown_realm | 401 | verify | The realm on the pending token no longer resolves |
invalid_token | 401 | protected routes | An mfa_pending token was presented outside verify |
rate_limited | 429 | verify | The auth_mfa bucket is empty |
lookup_failed | 500 | setup | Could not read the existing enrolment |
mfa_lookup_failed | 500 | gated flows | Could not decide whether MFA is required |
persist_failed | 500 | setup, enable | Could not write the enrolment row |
engine_unavailable | 500 | all | The tenant datastore could not be reached |
user_lookup_failed | 500 | verify | The identity row could not be loaded |
sign_failed | 500 | gated flows, verify | Token signing failed |
issue_failed | 500 | verify | The refresh-token row could not be persisted |
disable never returns missing_fields: a body it cannot decode is treated as an empty
code, which then fails the code check. The block-wide table is on the
error reference.
Events
Section titled “Events”Three auth-flow events fire on this surface: after_mfa_enable, after_mfa_disable
(emitted on both the success and the wrong-code path) and after_mfa_verify (emitted on
success, on a wrong code, and on a reused code). Each payload carries user_id and
method: "totp"; failures add a reason. A successful verify also emits
after_login_success with flow: "mfa_verify".
Not implemented
Section titled “Not implemented”None of the following exists in the shipping binary, and no configuration enables any of them:
- A second method of any kind — SMS OTP, email OTP, or a passkey used as a second factor.
- Recovery codes, backup codes, or trusted-device / “remember this browser” bypass.
- A tenant, realm, or role policy that requires enrolment.
- An operator or admin endpoint that clears a lost enrolment.
- Step-up re-authentication on a live session. The assurance level is recorded on managed session rows but is not a token claim, so no route can require it from a token.
- MFA on the control-plane operator login.