Skip to main content

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

  1. User opens Two-factor Auth in the SyAuth dashboard (/security/mfa).
  2. 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 (standard otpauth://totp/... URL)
  3. User scans the QR with any authenticator app (Google Authenticator, 1Password, Authy, Aegis, …) and enters the current 6-digit code.
  4. 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:

FieldNotes
device_typetotp today, webauthn / recovery reserved
secret_encryptedBase32 secret encrypted with CUSTOM_DB_ENCRYPTION_KEY (Fernet). Reused deliberately — a single KMS key for all app-level encryption.
confirmed_atNull while pending; set after the user enters a valid code once.
last_used_atTouched on every successful verification; surfaces in the dashboard so users can spot stale devices.
is_activeDisable 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:

EventSeverityWhen
mfa_enroll_startinfoTOTP enrollment started
mfa_enroll_confirmwarnDevice confirmed — MFA now active
mfa_disablewarnDevice disabled
mfa_verify_successinfoTOTP code accepted at login
mfa_verify_failurewarnTOTP 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-XXXXX using an unambiguous alphabet (no 0/O, no 1/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_at in 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

EventSeverityWhen
mfa_recovery_generatewarnUser generated or rotated their codes.
mfa_recovery_consumewarnA 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_generate into the post-password_reset_confirm flow 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 403 if 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.