Multi-factor authentication
Multi-factor authentication
Section titled “Multi-factor authentication”Viat supports TOTP authenticators and one-use recovery codes for password doors. Platform and project users have separate factors, policies and tickets. Provider OIDC/social login relies on the upstream provider’s assurance; Viat does not add a second password-factor challenge to every bearer request. API keys, PATs and refresh operations are not interactive TOTP logins. MFA is not an authorization-rule grant.
Policies and prerequisites
Section titled “Policies and prerequisites”Use HTTPS, a password-capable account, a trusted authenticator, correct server/device clocks and secure offline recovery-code storage. Never publish a TOTP QR/secret, recovery code or MFA ticket. Keep another platform Owner recovery path before enforcing policy.
- Platform
VIAT_MFA_REQUIRED:off(default; enrollment still available),admins(Owner/Admin),all; true maps to all, false to off. It applies platform roles, not project membership roles. Unknown policy text parses off, so use the documented literal and verify status. - Project
mfa_policy:offdefault,optional,required; Owner/Admin changes it through project settings.offignores enrolled project rows and refuses project self-service;optionalasks enrolled password users;requiredforces unenrolled password users through enrollment. Changing to off does not prove secrets were erased. - A platform account that has only external SSO and no password reports
available:false; do not try to enroll by inventing a local password.
Password login is a two-step protocol
Section titled “Password login is a two-step protocol”Correct password can return 401, not 200:
{"error":"mfa_required","message":"...","mfa_ticket":"11111111-2222-4333-8444-555555555555","expires_at":"2026-11-06T12:05:00Z"}The code may instead be mfa_enrollment_required. This is a challenge, not a rejected password. The ticket is valid for 300 seconds and at most five code attempts; it is scoped to account, purpose and identity plane. Per-account throttling also applies. Use the actual ticket from the response, never the illustrative UUID. A login ticket cannot be used as a bearer or as an enrollment ticket.
For enrolled platform users, POST /auth/mfa/verify takes required mfa_ticket and code; success returns the normal platform login response (access/refresh pair, identity and overview token). Project users use POST /p/{slug}/auth/mfa/verify with the same two required keys; native login completion returns its project login/session result, not a platform account. Hosted project login handles this navigation in its own forms.
Source-derived unexecuted platform request:
curl -sS "$BASE/auth/mfa/verify" -H 'Content-Type: application/json' \ -d '{"mfa_ticket":"11111111-2222-4333-8444-555555555555","code":"REPLACE_WITH_CURRENT_CODE"}'Enrollment and self-service routes
Section titled “Enrollment and self-service routes”| Action | Platform route | Project route |
|---|---|---|
| Status | GET /auth/mfa | GET /p/{slug}/auth/mfa |
| Begin enrollment | POST /auth/mfa/totp/setup | POST /p/{slug}/auth/mfa/totp/setup |
| Confirm | POST /auth/mfa/totp/confirm | POST /p/{slug}/auth/mfa/totp/confirm |
| Disable | DELETE /auth/mfa | DELETE /p/{slug}/auth/mfa |
| Replace recovery set | POST /auth/mfa/recovery-codes | POST /p/{slug}/auth/mfa/recovery-codes |
Platform self-service requires an interactive platform session, not a PAT. Project self-service requires the appropriate active project password-login identity and non-off policy, not a platform session or machine key.
- Setup from a signed-in platform session takes
{"password":"..."}. Project setup uses{"credential":"..."}. Alternatively, forced-enrollment setup uses{mfa_ticket}without a completed session; the enrollment ticket already represents a password check. Optional ticket/password keys may be omitted/null when not applicable. - Setup returns exactly
{secret,otpauth_uri,issuer,account}.secretis base32;otpauth_urican be imported by an authenticator. This is pending enrollment, not enforced MFA. Starting again can replace an unconfirmed secret. - Confirm takes required
code, optional/nullmfa_ticket. Platform confirm returns{recovery_codes,session?};sessionis included when enrollment was the login’s missing step, omitted for an already signed-in enrollment. Project confirm similarly completes the corresponding project login door; it is not a platform session. - Save recovery codes once in offline secure storage before dismissing the result. The server retains hashes; status cannot recover the strings.
- Verify status and a fresh login, including the challenge branch. Do not keep retrying a spent TOTP step; replay protection rejects reuse.
Status shared fields: enabled, pending, nullable confirmed_at, recovery_codes_remaining, nullable last_used_step. Platform adds required, available; project adds policy, required. A replay watermark ahead of the current time step can indicate clock rollback or restored state, not a mistyped code. Check server time and restore history before resetting accounts.
Disable/replace recovery set requires reauthentication: platform {password,code}, project {credential,code}, both required. code can be a current TOTP or unused recovery code. Disable returns {ok:true} and removes factor/recovery state. It is allowed even when policy requires MFA, so users can move authenticators; next password login will require enrollment again. Recovery replacement returns {recovery_codes:[...]} and immediately invalidates the previous set.
Loss, reset and sessions
Section titled “Loss, reset and sessions”Lost authenticator but have a recovery code: use one at the login’s verify step, then disable/re-enroll or regenerate the recovery set after reauthentication. Recovery codes are consumed once, not reusable spare passwords. Do not send them to an administrator in chat.
Lost both: verify the person’s identity out of band, then use the correct administration plane:
- Platform
DELETE /users/{email}/mfa: platform Owner and session only; an Owner PAT is rejected. Returns{ok,had_mfa,sessions_revoked}; clears factor and revokes platform refresh sessions and overview readers. It does not say all PATs were revoked. - Project
DELETE /projects/{slug}/users/{identity}/mfa: project editor (owner,admin,developer). Returns{ok,had_mfa}. It clears project MFA, but does not revoke project login/refresh sessions or bump the password epoch in this handler. If compromise rather than simple loss is possible, separately deactivate the user/change password and review keys/sessions; do not rely on the old “stateless project tokens” comment as a session-revocation guarantee.
Password changes are separate: platform PUT /auth/password uses {old_password,new_password,code?}; project POST /p/{slug}/auth/password uses {current,new,code?}. Existing MFA requires code when applicable. Removing a factor is not changing the password.
Failure handling and verification
Section titled “Failure handling and verification”Expired/exhausted/wrong-purpose ticket or wrong factor can yield 401; unavailable/policy-off and malformed requests fail rather than creating a factor; throttling returns 429/Retry-After. Restart from password login after an expired ticket. For pending enrollment attempt exhaustion, repeat legitimate setup rather than brute-force confirm. Never log ticket/code/secret bodies during diagnosis.
Negative checks: a project ticket must fail at platform verify and another project’s verify; a PAT must fail platform setup/reset; a machine key must not change a password; an old recovery set must fail after regeneration; unenrolled required-policy login must not get a normal session before confirmation. Use disposable accounts and restore their intended MFA policy afterward; administrator resets affect real account security and require explicit operator approval.
The console Account Security page implements password → setup → confirm → one-time recovery display; Sessions is a separate inventory. This reference did not execute authenticator enrollment, real SSO, provider calls or account resets.
Evidence: crates/viat-api/src/{mfa_engine.rs,handlers/mfa.rs,handlers/project_mfa.rs,handlers/users.rs,handlers/project_users.rs,auth_native.rs}, web/src/pages/settings/{SecurityPage.tsx,EnrolForm.tsx,RecoveryCodes.tsx,SessionsPage.tsx}. The project-reset/session discrepancy above is derived from the actual handler, not archived plans.