Skip to content
Talk to our solutions team

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.

MethodPathAuthRate limitPurpose
POST/auth/mfa/totp/setupBearernoneGenerate a pending TOTP secret
POST/auth/mfa/totp/enableBearernoneConfirm the pending secret with a live code
POST/auth/mfa/totp/disableBearernoneRemove the enrolment (requires a live code)
POST/auth/mfa/verifythe pending tokenauth_mfaComplete 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.

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:

ParameterValue
algorithmSHA1
digits6
period30
issuera 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.

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.

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

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.

FlowRouteShipped behaviour
Password loginPOST /auth/loginGate fires; the pending token carries the login realm
Magic-link verifyPOST /auth/magic/verifyGate fires; the pending token carries the realm users
Passkey assertionPOST /auth/webauthn/assert/finishGate is in the handler, but the route answers 503 webauthn_disabled in the shipping binary
Federated callbackGET /auth/oauth/:provider/callbackGate 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.

FlowRoute
Control-plane operator loginPOST /auth/login with realm: "superadmin"
Access-token refreshPOST /auth/refresh
First-superadmin bootstrapPOST /auth/bootstrap
API-key authenticationany route accepting Authorization: ApiKey
Service-token exchangePOST /auth/token
Managed-session exchangePOST /internal/session/exchange
Operator impersonationPOST /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 mfa_token is a PASETO v4.public signed with the same per-tenant Ed25519 key as access tokens. It carries only:

ClaimValue
issiam
subthe identity’s row id
tenantthe tenancy key the login was made against
realmthe realm the identity was authenticated in
typemfa_pending
iat / nbf / expissued-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.

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:

  1. Signature against the tenant’s keyring.
  2. The tenant claim equals the request’s tenancy key.
  3. The token is inside its validity window.
  4. type is mfa_pending.
  5. The subject still holds an active TOTP enrolment.
  6. The code verifies against the stored secret.
  7. The code’s 30-second step has not already been consumed.
  8. 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,
"user": { "id": "U01K…", "email": "[email protected]", "displayname": "" }
}

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.

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.

CodeHTTPWhereMeaning
auth_required401setup, enable, disableNo authenticated principal on the request
no_tenant400allTenancy headers missing
missing_fields400enable, verifycode, or mfa_token and code, absent
no_pending_setup400enableNo pending enrolment — call setup first
already_enabled409setup, enableTOTP is already active for this subject
invalid_code401enable, disable, verifyThe code did not verify for the current step
code_reused401verifyThat step was already consumed
invalid_mfa_token401verifyBad signature, another tenant, outside the 5-minute window, or type is not mfa_pending
mfa_not_enabled401verifyThe token’s subject holds no active enrolment
unknown_realm401verifyThe realm on the pending token no longer resolves
invalid_token401protected routesAn mfa_pending token was presented outside verify
rate_limited429verifyThe auth_mfa bucket is empty
lookup_failed500setupCould not read the existing enrolment
mfa_lookup_failed500gated flowsCould not decide whether MFA is required
persist_failed500setup, enableCould not write the enrolment row
engine_unavailable500allThe tenant datastore could not be reached
user_lookup_failed500verifyThe identity row could not be loaded
sign_failed500gated flows, verifyToken signing failed
issue_failed500verifyThe 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.

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

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.