MFA — first class, every plan.
GATOTP (the kind authenticator apps show) plus 10 single-use backup codes. No SMS fallback ever — see passkeys for a hardware-backed second factor, or Till Authenticator push approval for tap-to-confirm. Every route below is specified in the API reference.
How TOTP works here
- Spec: TOTP (RFC 6238) — standard 6-digit time-based codes. Compatible with every TOTP authenticator (1Password, Authy, Google Authenticator, Bitwarden, Aegis, …).
- Secret: generated with strong randomness and encrypted at rest; never returned after enrollment.
Enrollment
- Authenticated user calls
POST /v1/totp/setup. We mint a secret, create a pending enrollment (not yet enabled), and return anotpauth_uri, thesecretfor manual entry, and a ready-madeqr_pngdata URL. - The user scans the QR and is prompted for the first 6-digit code as proof they configured their authenticator correctly.
- Client posts
POST /v1/totp/verify-setupwith the code. On match the enrollment is enabled and the response carries two things: the backup codes, and a fresh token pair withmfa: true— store it, replacing the old one, so this session counts as factor-proven. - Backup codes — 10 of them, each shown once. Server-side they are stored hashed; the plaintext is gone the moment the user moves on.
- Audit rows:
totp.setup→totp.enabled.
current_password). Password-less accounts (magic-link or OAuth-born) proceed on their session alone — or, once any second factor exists, must present an mfa: true session (403 step_up_required otherwise). No account is locked out of enrolling by a password it never had.Challenge
After a primary sign-in succeeds against a TOTP-enrolled user, POST /v1/signin answers a challenge instead of a session:
{
"mfa_required": true,
"challenge_token": "…"
}The challenge token is short-lived and bound to this sign-in attempt. The client posts POST /v1/totp/verify-signin with { challenge_token, code } — code is either the 6-digit TOTP or a backup code:
- On match, a full
mfa: truesession is minted. - A wrong code is a 422 with a retryable message; attempts are rate-limited per user.
- A backup code is checked against the hashed set and consumed on use — it can never be replayed.
Magic-link sign-ins step up the same way: /v1/magic/consume returns the same challenge shape when TOTP is enrolled.
Backup codes
10 codes are generated at enrollment, each single-use; consuming one removes it from the set. The remaining count is visible to your UI as factors.backup_codes on POST /v1/me — surface it, and prompt re-enrollment when it runs low.
There is no separate regenerate endpoint: to issue a fresh set, disable TOTP and enrol again (a deliberate re-proof of the authenticator, not just a button).
Disabling MFA
POST /v1/totp/disable, with the same proof ladder as setup: password holders confirm it, password-less users present an mfa: true session. On success the secret and remaining backup codes are destroyed; audit row totp.disabled.
Forcing MFA enrollment
Per-app owner choice in the dashboard (App → Settings → Sign-in policy), default optional. On a required app, users without a factor are routed into enrollment before receiving a full session, and policy changes that would strand existing users (nothing left to enrol) are rejected at write time.
What we don't do
- SMS 2FA — phishing-friendly and SIM-swap-vulnerable. We won't ship it; if your compliance requires “MFA via SMS”, offer passkeys.
- Email 2FA — the second factor is the channel that proved the first. Same channel = no second factor.
Push approval, previously on this list, now exists properly: Till Authenticator binds a device-held signing key with number-matching — /v1/signin answers method: "push" and the browser polls /v1/push/signin/poll. See the API reference.
Next: magic links · passkeys for the strongest UX · or embedded account settings to build this into your own UI.