TOTP MFA
IdentSphere implements RFC 6238 TOTP — compatible with Google Authenticator, 1Password, Authy, Bitwarden, and every other authenticator app on the market.
Enrollment flow
[client] [IdentSphere]
│ POST /v1/auth/mfa/setup │
│ ───────────────────────────►│
│ │ mint 20-byte secret
│ { secret, qr_code_base64 } │ persist as user.mfa_secret
│ ◄─────────────────────────── │ mfa_enabled stays false
│ │
│ user scans QR in app │
│ │
│ POST /v1/auth/mfa/enable │
│ { code: "123456" } │
│ ───────────────────────────►│
│ │ verify code (±1 window)
│ { recovery_codes: [...] } │ mfa_enabled = true
│ ◄─────────────────────────── │ insert 10 recovery codes
The client must show the recovery codes to the user EXACTLY ONCE; the plaintext is never recoverable after this response.
Login flow (MFA enrolled)
POST /v1/auth/login → 200 { status: "mfa_required", mfa_token, mfa_token_expires_in: 300 }
POST /v1/auth/mfa/challenge { mfa_token, code }
→ 200 { status: "success", user, capabilities, access_token, ... }
The mfa_token is short-lived (5 min) and single-use. A successful
challenge "burns" the token's JTI so concurrent or replayed challenges
fail.
Recovery codes
10 alphanumeric 10-char codes are issued at enrollment. They're stored as SHA-256 hashes. The user can spend each one ONCE to sign in if they lose their authenticator.
Regenerate via POST /v1/auth/mfa/recovery-codes
— password-gated.
Step-up
For sensitive operations inside an authenticated session, call
POST /v1/auth/mfa/verify with the user's current TOTP
code. The middleware stores a "recently verified" assertion in
session_cache keyed by session_family_id for the configured TTL
(default 30 min).
Recovery codes are NOT accepted for step-up — they're for sign-in recovery only.
Brute-force protection
The challenge endpoint counts failed attempts per mfa_token JTI. After 5
failures, further attempts return 429 rate_limited for the rest of the
token's 5-minute lifetime.
Disabling
Password-gated. Calling POST /v1/auth/mfa/disable:
- Verifies the user's current password.
- Sets
mfa_enabled = false. - Clears
mfa_secret. - Deletes all recovery codes.
- Invalidates step-up cache entries for the user's active sessions.
Storage details
| What | Where |
|---|---|
| TOTP secret | users.mfa_secret (base32, RFC 4648 no padding) |
| Recovery code hashes | user_recovery_codes.code_hash (SHA-256 hex) |
| Step-up assertion | session_cache key IdentSphere:mfa_verified:{sid} |
| Challenge attempts | session_cache key IdentSphere:mfa_attempts:{jti} |
| Burned challenge tokens | session_cache key IdentSphere:mfa_burned:{jti} |
What MFA does NOT do
- Phone-based SMS codes. SS7 is too leaky and we don't want to ship a product that recommends it.
- Push notifications. Out of scope for v0.1.
- WebAuthn (passkey). That's a separate flow — see Passkeys.
For phishing-resistant MFA, prefer passkeys over TOTP.