Skip to content

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 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/enroll
Cookie: <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/activate
Cookie: <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.

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.

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.

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.

POST /mfa/totp/disable
Cookie: <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.

GET /mfa/methods
Cookie: <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.

totp_enrolled, totp_activated, totp_disabled, and login_success with the method recorded. See audit logs.