Two-factor Authentication
SyAuth ships with TOTP-based MFA out of the box. Once a user enrolls a TOTP device, every login — password, magic-link, or any other primary factor — is gated on a current 6-digit code.
The model (UserMFADevice) is designed to host WebAuthn / passkey rows alongside TOTP in the future. No schema change is needed when WebAuthn support is added.
End-user flow
- User opens Two-factor Auth in the SyAuth dashboard (
/security/mfa). - Clicks Start enrollment — the server generates a TOTP secret, encrypts it at rest with Fernet, and returns:
device_id(opaque UUID)secret(base32, shown once to render QR code)otpauth_uri(standardotpauth://totp/...URL)
- User scans the QR with any authenticator app (Google Authenticator, 1Password, Authy, Aegis, …) and enters the current 6-digit code.
- SyAuth validates the code (±30 s drift tolerated) and marks
confirmed_at. From this point MFA is required for every login.
Removing MFA is a click on Disable in the same page.
Login gate
After password verification in LoginView (or code consumption in MagicLinkConsumeView), SyAuth calls MFAService.user_requires_mfa(user). If it returns true, the response is:
{
"mfa_required": true,
"mfa_ticket": "…",
"mfa_methods": ["totp"]
}
No access token is issued. The client then collects the user's TOTP code and posts it to the public endpoint:
POST /auth/mfa/verify/
{ "mfa_ticket": "…", "code": "123456" }
On success — the response matches a normal login:
{
"user": { ... },
"token": "eyJhbGci…",
"refresh_token": "eyJhbGci…",
"message": "Login successful"
}
An ExtsUserSession is created (same ledger as other logins; see the Sessions doc).
Tickets are single-use. The cached ticket is deleted the first time it's consumed — a valid or invalid code both burn the ticket, so an attacker cannot brute-force TOTP offline from a leaked ticket.
Ticket TTL is 5 minutes by default (MFAService.TICKET_TTL_SECONDS).
Enrollment API reference
All authenticated — require Authorization: Bearer <access_token> of the enrolling user.
GET /user/mfa/devices/
List every MFA device on the current account (confirmed, pending, and disabled). Response:
{
"results": [
{
"id": "…",
"device_type": "totp",
"name": "Personal phone",
"is_confirmed": true,
"is_active": true,
"confirmed_at": "2026-04-17T11:00:00Z",
"last_used_at": "2026-04-17T11:09:00Z",
"created_at": "2026-04-17T10:58:00Z"
}
]
}
POST /user/mfa/totp/enroll/
Starts a TOTP enrollment. Body is optional: { "name": "My phone" }.
Response — the secret is only shown once:
{
"device_id": "…",
"secret": "JBSWY3DPEHPK3PXP",
"otpauth_uri": "otpauth://totp/SyAuth:[email protected]?secret=JBSWY3DPEHPK3PXP&issuer=SyAuth"
}
Calling this endpoint again before confirmation reuses the pending device rather than creating a second row, so retries don't clutter the table.
POST /user/mfa/totp/confirm/
{ "device_id": "…", "code": "123456" }
Verifies the code against the stored secret and sets confirmed_at. From here on the device is part of the login gate.
POST /user/mfa/totp/disable/
{ "device_id": "…" }
Deactivates the device. If this was the user's only confirmed device, MFA is no longer required.
Data model
UserMFADevice fields worth knowing:
| Field | Notes |
|---|---|
device_type | totp today, webauthn / recovery reserved |
secret_encrypted | Base32 secret encrypted with CUSTOM_DB_ENCRYPTION_KEY (Fernet). Reused deliberately — a single KMS key for all app-level encryption. |
confirmed_at | Null while pending; set after the user enters a valid code once. |
last_used_at | Touched on every successful verification; surfaces in the dashboard so users can spot stale devices. |
is_active | Disable toggle. |
Secrets are never returned from the API after the enrollment call. A compromised database dump yields ciphertext only; an attacker still needs the Fernet key to derive codes.
Audit events
Each step of the MFA lifecycle is recorded to the Audit Log:
| Event | Severity | When |
|---|---|---|
mfa_enroll_start | info | TOTP enrollment started |
mfa_enroll_confirm | warn | Device confirmed — MFA now active |
mfa_disable | warn | Device disabled |
mfa_verify_success | info | TOTP code accepted at login |
mfa_verify_failure | warn | TOTP code rejected at enrollment or login |
Recovery codes
Losing every TOTP device and every passkey at once is how real users get locked out of their own accounts. SyAuth ships 10 single-use recovery codes per user as a break-glass path — printed, stored in a password manager, or taped inside a desk drawer.
Properties
- Ten codes per user, format
XXXXX-XXXXXusing an unambiguous alphabet (no0/O, no1/I). - Stored SHA-256 hashed; the plaintext is returned once at generation time and is never recoverable thereafter.
- Single-use — each successful verify marks the code
used_atin the JSONB store. Replays fail immediately. - Regenerating invalidates every existing code, used or not.
- Not a primary factor. Having only recovery codes does not enable MFA — the login gate still requires a TOTP or passkey first. Recovery is the fallback when that factor is unavailable.
Enrollment
In the SyAuth dashboard (/security/mfa), the Recovery codes panel shows:
- How many unused codes are left (badge turns yellow at ≤ 2).
- A Generate recovery codes button on first use, or Regenerate once codes exist.
The plaintext grid is shown exactly once, with a Copy all helper that drops \n-separated codes on the clipboard. There is no retrieval path; the codes are gone the moment the user dismisses the modal.
Using a recovery code at sign-in
The MFA challenge accepts either a TOTP code or a recovery code in the same input. The server tries TOTP first; on failure it falls back to recovery-code verification. Successful recovery codes are audit-logged separately (mfa_recovery_consume) so you can tell at a glance when a user has been using break-glass codes — a signal that their primary factor might need re-enrolling.
When mfa_methods includes recovery, the hosted UI adds a small hint under the TOTP input: "No authenticator? Paste a recovery code here instead."
API reference
All authenticated — require Authorization: Bearer <access_token>.
GET /user/mfa/recovery/status/
{ "remaining": 7, "total": 10 }
POST /user/mfa/recovery/generate/
Body: none. Generates or rotates the user's codes. Response includes the plaintext exactly once:
{
"codes": [
"Q4TXK-PBHRM",
"Z7W2A-MCFDX",
"…"
],
"total": 10,
"_warning": "Store these codes now. They will never be shown again. Any previously-issued codes are now invalid."
}
The existing POST /auth/mfa/verify/ endpoint accepts recovery codes transparently — same request shape as TOTP.
Audit events
| Event | Severity | When |
|---|---|---|
mfa_recovery_generate | warn | User generated or rotated their codes. |
mfa_recovery_consume | warn | A valid recovery code was used at login. metadata.remaining shows how many are left afterwards. |
Operational notes
- At ≤ 2 remaining, prompt the user to regenerate. The dashboard already badges this in yellow; consider sending an email reminder via a nightly cron if you want to get ahead of support tickets.
- Rotating codes is cheap. One row per user. Regeneration is a single
save(), not a cascade of deletes. - Consider forcing regeneration on password change. A user who just reset their password after suspected account takeover should probably rotate their recovery codes too — wire
mfa_recovery_generateinto the post-password_reset_confirmflow as a follow-up.
Policy recommendations
- Require MFA for workspace owners. Today MFA is user-opt-in. Add a per-workspace policy and enforce it in
LoginView(return 403if owner lacks a confirmed device). - Step-up MFA on risky actions. Rotating an OAuth client secret or changing email should re-challenge MFA even for an already-authenticated session.