Multi-factor authentication (MFA)
TOTP, email codes, recovery codes, step-up verification and MFA mandates per organization or group: no primary factor bypasses MFA.
Obexal treats MFA as a server-side invariant, not a UI feature: once an account has an active second factor, no primary factor can open a session without it.
Second factors at a glance
| Factor | Type | Validity |
|---|---|---|
| TOTP | Authenticator app (RFC 6238, 6 digits, 30 s period) | Current code, 1 period of clock skew tolerated |
| Email code | Numeric one-time code sent to the verified address | 10 minutes |
| Recovery codes | 10 single-use backup codes | Until used or regenerated |
There is no SMS factor, by design: SMS delivery would route authentication data through non-EU aggregators and remains exposed to SIM swapping, so Obexal excludes it as a sovereign choice.
The MFA chokepoint
Every primary factor terminates in the same server-side decision point: password, passwordless email, social login, SAML and LDAP. If the account has an active MFA factor, no session is opened; the API returns a challenge instead:
{ "mfaRequired": true, "mfaToken": "kJ2m...", "methods": ["totp"] }The client completes it with the second factor:
curl -sS -X POST https://accounts.obexal.com/v1/auth/mfa \
-H "X-CSRF-Token: $CSRF" -H 'Content-Type: application/json' \
-d '{"mfaToken":"kJ2m...","code":"123456"}'
# 200 -> {"user":{...}} and the session cookie is setThe challenge is single use and stored hashed, expires after 5 minutes (10 minutes for email codes, so the email has time to arrive), allows at most 5 attempts, and verification is rate limited per IP. TOTP takes priority: a user who has both factors is challenged with TOTP. The one deliberate exception to the chokepoint is the passkey, itself a strong, phishing-resistant primary factor.
Examples use accounts.obexal.com; replace it if your organization signs in on a custom domain.
Enroll and manage TOTP
Enrollment requires a session and follows a pending to active transition:
# 1. Enroll: creates a PENDING factor, returns the secret to scan (only shown here).
curl -sS -b cookies.txt -X POST https://accounts.obexal.com/v1/mfa/totp/enroll \
-H "X-CSRF-Token: $CSRF"
# 200 -> {"secret":"JBSWY3DPEHPK3PXP","otpauthUri":"otpauth://totp/Obexal:a@b.eu?..."}
# 2. Activate: a valid current code flips the factor to ACTIVE.
curl -sS -b cookies.txt -X POST https://accounts.obexal.com/v1/mfa/totp/activate \
-H "X-CSRF-Token: $CSRF" -H 'Content-Type: application/json' -d '{"code":"123456"}'
# 200 -> {"recoveryCodes":["...", "..."]} (10 codes, shown once)The TOTP secret is encrypted at rest. Disabling the factor (POST /v1/mfa/totp/disable) requires a valid current TOTP code: a hijacked session cannot silently remove the second factor. GET /v1/mfa lists the account's factors and their status.
Email codes as a second factor
The email code factor can be enrolled explicitly: POST /v1/mfa/email/enroll sends a code to the account's verified address and returns an mfaToken; POST /v1/mfa/email/activate with {"mfaToken","code"} activates the factor; POST /v1/mfa/email/disable removes it.
It is also applied by default as a platform policy: a password sign-in by a user who has no TOTP receives an emailed code challenge, so the password alone never opens a session. If the email is slow to arrive, request a new code:
curl -sS -X POST https://accounts.obexal.com/v1/auth/mfa/resend \
-H "X-CSRF-Token: $CSRF" -H 'Content-Type: application/json' \
-d '{"mfaToken":"kJ2m..."}'
# 200 -> {"mfaToken":"<new token>","codeLength":6}Resending is rate limited per IP and each resend invalidates the previous challenge token.
Recovery codes
Activating TOTP generates 10 single-use recovery codes, returned in clear exactly once and stored hashed. Each one can complete an MFA challenge in place of a TOTP code ({"mfaToken":"...","recoveryCode":"..."}), for example after losing the phone.
POST /v1/mfa/recovery-codes/regenerate (session required, active TOTP required) replaces the whole batch: the previous codes become invalid immediately.
Step-up verification for sensitive actions
A valid session is not always enough. Sensitive endpoints require a fresh second-factor verification:
curl -sS -b cookies.txt -X POST https://accounts.obexal.com/v1/mfa/step-up \
-H "X-CSRF-Token: $CSRF" -H 'Content-Type: application/json' -d '{"code":"123456"}'
# 204 -> the session is marked MFA-fresh for 5 minutesWithout a fresh step-up, sensitive endpoints (for example updating an AI agent's identity or policy caps, or rotating an agent secret) respond 403 step_up_required. A recovery code is accepted in place of a TOTP code. Anti-lockout rule: a user with no MFA factor is not gated, since they could never pass the step-up.
Require MFA for an organization or a group
Administrators can make MFA mandatory:
- Per organization: in the console, organization settings, enable the MFA requirement (API: the
requireMfafield ofPATCH /v1/admin/tenant). - Per group: set
requireMfawhen creating or updating a group; every member is then under the mandate.
curl -sS -X POST https://accounts.obexal.com/v1/admin/groups \
-H "Authorization: Bearer $OBEXAL_API_TOKEN" -H 'Content-Type: application/json' \
-d '{"name":"Finance","requireMfa":true}'At sign-in, a user under mandate who has no factor yet gets a session flagged mfaEnrollmentRequired: true, and the hosted sign-in UI forces enrollment before anything else. Self-hosted deployments can additionally set MFA_ENROLLMENT_STRICT=true: the server then refuses authenticated requests (403 mfa_enrollment_required) until a factor is enrolled, allowing only the enrollment endpoints, me, logout and csrf. See Configuration.
Conditional access rules can also require MFA dynamically, based on network, schedule or country.