MFA — first class, every plan.
TOTP (the kind authenticator apps show) plus 10 single-use backup codes. No SMS fallback ever — see passkeys if you want a hardware-backed second factor instead.
How TOTP works here
- Spec: RFC 6238 — HMAC-SHA1, 6 digits, 30-second step. Compatible with every TOTP authenticator (1Password, Authy, Google Authenticator, Bitwarden, Aegis, …).
- Drift window: ±1 step. We accept the current code, the previous one, or the next one — covers normal clock drift without significantly widening the brute-force surface.
- Secret: 160 bits of randomness, encoded as Base32 for the QR code. Stored AES-256-GCM-encrypted with the per-app DEK; never returned after enrollment.
Enrollment
- Authenticated user calls
POST /v1/mfa/totp/setup. We mint a secret, persist a pending row intillauth_totpwithenabled_at = null, and return anotpauth://URL. - The client renders a QR code from the URL. The user scans it and is prompted for the first 6-digit code as proof they configured their authenticator correctly.
- Client posts
POST /v1/mfa/totp/verifywith the code. On match, we setenabled_at = now()and generate the backup codes. - Backup codes — 10 of them, each shown once. Customer-side, store them somewhere safe. Server-side, we store
sha256(code)intillauth_totp.backup_codes_hashed; the plaintext is gone the moment the user closes the modal. - Audit row:
mfa.totp.enrolled.
Challenge
After a primary signin succeeds against an MFA-enrolled user, we return
{
"ok": false,
"mfa_required": true,
"challenge_token": "mfa_ch_…",
"factors": ["totp", "backup_code"]
}The challenge token is a JWT bound to this signin attempt with a 5-minute TTL. The client postsPOST /v1/mfa/totp/challenge with { challenge_token, code }.
- If the code matches inside the drift window, we mint the session and write
signin.password.okwithmetadata.mfa = "totp". - If it doesn't,
mfa.totp.bad_codeis written; the user retries against the same challenge token until it expires. - If the user submits a backup code instead of a TOTP code, we sha256-hash and check the set; on match we consume the row (so it can't be reused).
Backup codes
10 codes are generated at enrollment, formatted asxxxx-xxxx-xxxx. Each is single-use; consuming one removes it from backup_codes_hashed. The user's UI shows how many remain, and they can regenerate the full set — which invalidates all the previous ones — at any time.
Regenerate via POST /v1/mfa/totp/regenerate-backup-codes with a fresh TOTP code as proof-of-presence.
Disabling MFA
Users hit DELETE /v1/mfa/totp with a current TOTP code (or backup code). On success we destroy the secret and clear the backup codes; audit row mfa.totp.disabled is written. Admins can also force-disable through the per-user admin page when a user has lost everything.
Forcing MFA enrollment
Per-app setting: settings.mfa_required = true. Users without enrolled MFA who try to sign in get the mfa.totp.required outcome and are routed into the enrollment flow before getting a full session. The challenge endpoint is the same; the only difference is the response telling the SDK to redirect into setup first.
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.
- Push notifications — they look pretty but require a customer-side mobile SDK we don't ship. Use passkeys if you want a tap-to-confirm UX.
- Email 2FA — the second factor is the channel that proved the first. Same channel = no second factor.
Next: magic links · or passkeys for the strongest UX.