TOTP MFA
TOTP (RFC 6238) is the second factor for users who cannot or will not use passkeys. It is implemented on WebCrypto HMAC, with single-use time steps and single-use recovery codes.
Enrollment
Section titled “Enrollment”Enrollment requires an authenticated session — like passkey registration, arming a second factor binds account control to something the caller holds, so the subject comes from the session, never from the request body.
POST /mfa/totp/enrollCookie: <tenant session>{ "secret": "JBSWY3DPEHPK3PXP", "otpauth_uri": "otpauth://totp/acme.oauth.work:ada@acme.com?secret=…&issuer=acme.oauth.work"}Render otpauth_uri as a QR code. Enrolling while TOTP is already active returns 409 totp_already_active.
At this point TOTP is pending, not armed. The user must prove a code before it takes effect:
POST /mfa/totp/activateCookie: <tenant session>
{ "code": "123456" }{ "ok": true, "recovery_codes": ["…", "…", "…", "…", "…", "…", "…", "…"]}Eight recovery codes, shown once. Requiring a valid code before arming is what stops a user from locking themselves out by scanning a QR code that never made it into their authenticator.
At login
Section titled “At login”Once TOTP is active, every first factor — code, link, password, social — stops short of a session and returns a pending MFA token instead:
{ "ok": true, "mfa_required": true, "mfa_token": "mfa_…" }POST /login/mfa/verify
{ "mfa_token": "mfa_…", "code": "123456" }or, if the authenticator is lost:
{ "mfa_token": "mfa_…", "recovery_code": "…" }The mfa_token is short-lived, attempt-capped, and burned on success or on exhausting its attempts
— so it cannot be used to grind codes. On success a session is created and amr gains the method
used: ["pwd","totp"], or ["pwd","mfa_recovery"] when a recovery code was spent.
Because every first factor funnels through one completion path, enabling TOTP covers all of them at once. There is no method that quietly skips it.
Replay
Section titled “Replay”An accepted time step is single-use. Presenting the same code twice within its window is rejected even though the code is arithmetically still valid — otherwise a code shoulder-surfed or captured in transit would work for the rest of its 30-second step.
Recovery codes
Section titled “Recovery codes”Eight, single-use, shown once at activation. A spent code cannot be reused, and spending one records
mfa_recovery in amr — so you can tell from the token that this session was established through
recovery rather than through the authenticator, and prompt the user to re-enroll.
Disabling
Section titled “Disabling”POST /mfa/totp/disableCookie: <tenant session>Writes a totp_disabled audit event. This is one worth alerting on: an attacker with a live session
disabling a second factor is a recognizable step in an account takeover.
What is enrolled
Section titled “What is enrolled”GET /mfa/methodsCookie: <tenant session>{ "totp": true, "password": true, "passkeys": 2 }What the account-security screen renders, and what your own UI would need to decide whether to prompt a user to add a factor.
Audit events
Section titled “Audit events”totp_enrolled, totp_activated, totp_disabled, and login_success with the method recorded.
See audit logs.