On this page0
Unfamiliar word? See Glossary: words used in these docs. Looking for the tasks in order? Begin at Start Here.
Reference
Included in: All plans. Which Plan Includes What
MFA Enforcement
Zonitix supports two kinds of second factor at sign-in, passkeys and authenticator-app codes, with single-use recovery codes and an administrator reset for someone who is locked out.
What It Does
Standard passwords are vulnerable to phishing, keyloggers, and reuse leaks. By enabling global MFA enforcement, you require everyone who signs in with a Zonitix password to link an authenticator application or device-level passkey. This maps directly to the SOC 2 Trust Services Criteria for logical access controls.
The switch is under Security & Compliance → Security Settings → MFA: Mandatory MFA for local accounts, in the ORG-WIDE DEFAULT panel. A new instance starts with it on. Each group can follow that default, require MFA, or be exempt; the PER-GROUP ENFORCEMENT panel on the same screen summarises those choices. Administrators, and anyone holding the permission to manage security, to manage users or to mark approved invoices as paid, always need a second factor at sign-in. No group exemption lifts that, and neither does ticking Trust this device for 30 days. Someone who loses their device signs in with a recovery code; if they have none left, an administrator uses Reset MFA on the Users page and the person enrols again at their next sign-in.
Phishing Defense
Emergency Recovery
Trusted Devices Do Not Apply To Privileged Accounts
Passkeys (WebAuthn)
A passkey lets a person sign in with a fingerprint, face or hardware security key (for example Face ID, Touch ID or a YubiKey) instead of typing a code.
If something here is not working as you expect, start with When something goes wrong.
The plain explanation is above. This part is for IT and developers: the exact settings, permission names, API addresses and data fields behind it. Closed: select the heading above to open it. While it is closed, your browser’s find-on-page (Ctrl+F or Cmd+F) does not search this part.
The MFA Endpoint Map
All routes below are exposed through the API gateway under /api/v1/auth and proxied to the identity-service, which owns every gate. Where the identity service answers 401 on these routes, the gateway returns 400 to the caller with the same code, so the statuses below are the ones a client receives. Enrollment and login sit under /mfa; the two operator escape hatches sit under /security and are the only privileged ones. The Auth column is the session type or permission the identity service actually checks.
| Method | Path | Purpose | Auth |
|---|---|---|---|
| POST | /api/v1/auth/mfa/setup/init | Stage a TOTP secret and return its otpauth URL. Secret is PENDING until a code confirms it. | MFA-challenge or full session token |
| POST | /api/v1/auth/mfa/setup/confirm | Verify the first TOTP code, promote the secret to live, enable MFA, mint recovery codes if none exist. | MFA-challenge or full session token |
| POST | /api/v1/auth/mfa/verify | Complete step-up at login with a TOTP code. | MFA-challenge session token (isMfaSession) |
| POST | /api/v1/auth/mfa/verify-recovery | Complete step-up at login with a single-use recovery code (consumes it). | MFA-challenge session token (isMfaSession) |
| POST | /api/v1/auth/mfa/recovery-codes/regenerate | Mint a fresh set of recovery codes, invalidating the previous set. | Full session, mfaVerified === true |
| POST | /api/v1/auth/security/mfa/reset/:userId | Wipe every factor for a locked-out user so they re-enroll at next login. | manage_security + sudo (step-up) |
| POST | /api/v1/auth/security/devices/revoke/:userId | Drop a user’s trusted devices without touching their factors. | manage_security + sudo (step-up) |
TOTP Enrollment: Init → Confirm
Enrollment is a two-step handshake so that rendering a QR never half-commits an account. setup/init stages the secret in mfaPendingSecret and returns it; setup/confirm checks a live code against that pending secret and only then promotes it to the live mfaSecret, sets mfaEnabled = true, and returns a full session token. Both steps authenticate with the enrollment token (in the mfaToken body field or the Authorization bearer) and both reject a magic-link or password-change token, and refuse to re-enroll an account that already has MFA unless the session is itself MFA-verified.
// Step 1 — stage a pending TOTP secret
POST /api/v1/auth/mfa/setup/init
// Auth: Bearer <mfa-challenge or full session token> (or body { "mfaToken": "..." } )
curl -X POST https://<your-instance>-api.zonitix.com/api/v1/auth/mfa/setup/init \
-H "Authorization: Bearer $MFA_TOKEN"
// 200 response — note the fields: `secret` (base32) and `otpauth_url`:
{ "secret": "JBSWY3DPEHPK3PXP",
"otpauth_url": "otpauth://totp/Zonitix:jane@acme.co?secret=JBSWY3DPEHPK3PXP&issuer=Zonitix" }
// Errors: 400 no/expired token (code MFA_SESSION_EXPIRED) · 403 wrong session
// type or already-MFA account with an unverified session · 404 user not found
// Step 2 — prove a code; `token` is the 6-digit TOTP and is REQUIRED
POST /api/v1/auth/mfa/setup/confirm
curl -X POST https://<your-instance>-api.zonitix.com/api/v1/auth/mfa/setup/confirm \
-H "Authorization: Bearer $MFA_TOKEN" -H "Content-Type: application/json" \
-d '{ "token": "123456" }'
// 200 — full session token, plus recoveryCodes ONLY on the first enrollment:
{ "auth": true, "token": "<full-session-jwt>",
"recoveryCodes": ["a1b2c3d4", "..."] } // null if the account already had codes
// Errors: 400 missing code / no setup in progress / invalid code · 403 wrong session type
The QR Is Client-Side
setup/init returns the otpauth_url string, not a rendered image. The authenticator app (or the enrollment UI) turns that URL into the QR — the server never generates one.Issuer Is Hardcoded
Zonitix, baked into the identity service. It is not an MFA_ISSUER environment variable and does not follow your store brand name — verify the label reads Zonitix, not your company.Recovery Codes Are Not Re-Minted On Add
setup/confirm only mints recovery codes when the account has none. A user adding TOTP alongside an existing passkey keeps their already-issued codes and gets recoveryCodes: null back — deliberately, so a printed set is never silently voided. To rotate codes, use the explicit regenerate endpoint below.
Login Step-Up: Verify & Consume A Recovery Code
At login, after the password check, the identity service hands back a short-lived MFA-challenge token (isMfaSession). The client completes step-up by posting either a TOTP code to /mfa/verify or a recovery code to /mfa/verify-recovery. Both require that challenge token — a full session or password-change token is rejected with 403. A recovery code is matched by bcrypt against the stored hashes and the first unused match is flagged used: true before the session is issued, so every code works exactly once.
// TOTP at login — mfaToken is the challenge token from the password step
POST /api/v1/auth/mfa/verify
{ "mfaToken": "<mfa-challenge-jwt>", "token": "123456", "rememberDevice": false }
// 200 → { "auth": true, "token": "<full-session-jwt>", "trustedDeviceToken": null }
// trustedDeviceToken is non-null ONLY when rememberDevice is true; the gateway
// then sets it as a cookie so that browser skips MFA until it expires — for
// non-privileged users only; privileged-floor accounts are re-challenged every time.
// 400 NO_TOTP_ENROLLED if the account has no authenticator app (use a passkey or
// recovery code) · 400 invalid code · 400 TOTP_REPLAY (that code was already used) ·
// 400 MFA_SESSION_EXPIRED (too many wrong codes on this sign-in) · 423 MFA_LOCKED ·
// 403 token is not an MFA-challenge session
// Recovery code at login — one-time; the matched code is burned on use
POST /api/v1/auth/mfa/verify-recovery
{ "mfaToken": "<mfa-challenge-jwt>", "recoveryCode": "a1b2c3d4", "rememberDevice": false }
// 200 → same shape as /verify (auth, token, trustedDeviceToken)
// 400 missing recoveryCode, the account has no codes at all, or the code is wrong
// OR already used · 403 not an MFA-challenge session
// Invariant to check: replaying a code that succeeded once now returns 400 —
// the `used` flag persists, so a captured code cannot be reused.
Attempt Limits And Replay Protection
Guessing a second factor is capped at three levels, and the same counters cover both the authenticator-code and the recovery-code endpoints:
- Per sign-in: 5 wrong codes. The fifth wrong code on one MFA-challenge token burns that token and returns 400
MFA_SESSION_EXPIRED. The person has to enter their password again to get a new challenge. - Per account: 10 wrong codes in 15 minutes. Counted across any number of sign-ins and addresses, and not reset by re-entering the password — only by a successful second factor. Reaching it locks the account for 15 minutes (the same lock a run of wrong passwords applies) and returns 423
MFA_LOCKED; it is audited asLOGIN_MFA_LOCKED_OUT. - Per client IP: 20 failed attempts in 5 minutes at the gateway, across the MFA and passkey endpoints. Successful verifications are not counted. Exceeding it returns 429.
Authenticator Code Parameters
- Standard TOTP (RFC 6238). The server passes no digit count, period or hash to its TOTP library, so the library’s defaults apply: 6 digits, a new code every 30 seconds, HMAC-SHA1. The secret is handed to the authenticator app in base32, as text and inside an
otpauth://address with the issuerZonitix. Any authenticator app that follows the standard works. - Accepted window: one step either side. Verification is called with
window: 1, at enrolment and at sign-in alike. The server accepts the code for the current 30 seconds, the one before and the one after, which allows for a clock that is up to about 30 seconds out and means a code can be accepted for up to about 90 seconds. - Used codes are remembered for 90 seconds, per account, which covers that whole window; this is what makes a code single-use (next card).
- None of these values is a setting. They cannot be changed from the app.
A Code Works Once
TOTP_REPLAY and audited as LOGIN_MFA_REPLAY — so a code that was overheard or phished cannot be replayed.Rotating Recovery Codes
/mfa/recovery-codes/regenerate is the only way to get a replacement set. The first set is minted by whichever factor is enrolled first — confirming an authenticator app or registering a passkey — whenever the account has no codes. It replaces the caller’s entire set and returns the fresh raw codes exactly once. Because it invalidates the old set, it demands a full, already-MFA-verified session: the token must not be an MFA-challenge, magic-link or password-change token, must carry a username, and must have mfaVerified === true — otherwise 403 MFA_VERIFICATION_REQUIRED.
POST /api/v1/auth/mfa/recovery-codes/regenerate
curl -X POST https://<your-instance>-api.zonitix.com/api/v1/auth/mfa/recovery-codes/regenerate \
-H "Authorization: Bearer $SESSION" # cookie is also accepted
// 200 → the new set (10 codes), shown once and never retrievable again:
{ "recoveryCodes": ["a1b2c3d4", "e5f6a7b8", ... 10 total ] }
// 400 no/expired token · 403 wrong session type or mfaVerified !== true
// (code MFA_VERIFICATION_REQUIRED) · 404 user not found
Regenerating Voids The Previous Set Immediately
The endpoint overwrites recoveryCodes wholesale and audits it as MFA_RECOVERY_CODES_REGENERATED with the count of previous codes invalidated. Any previously printed or saved code stops working the instant this returns. Capture the new set before leaving the screen — only bcrypt hashes are stored, so the raw codes cannot be shown again.
Operator Lockout Recovery
Two privileged endpoints handle the cases a user cannot fix themselves. Both are gated at the identity route on manage_security and requireSudo (a fresh step-up), take the target as a path parameter, and are audited. The gateway forwards the caller’s x-user, internal service token and Authorization bearer — requireSudo reads sudoGrantedAt from that bearer.
| Method | Path | Purpose | Auth |
|---|---|---|---|
| POST | /api/v1/auth/security/mfa/reset/:userId | Destructive: destroys all passkeys, revokes all trusted devices, clears mfaSecret / pending / recovery codes and mfaEnabled. User re-enrolls at next login. | manage_security + sudo |
| POST | /api/v1/auth/security/devices/revoke/:userId | Containment: revokes only the user’s trusted devices. Factors are kept, so the user still has their passkey / authenticator. | manage_security + sudo |
// Full MFA reset — the escape hatch for a user who lost their only factor
POST /api/v1/auth/security/mfa/reset/42
curl -X POST https://<your-instance>-api.zonitix.com/api/v1/auth/security/mfa/reset/42 \
-H "Authorization: Bearer $ADMIN_SESSION" # must be within the sudo window
// 200 → counts of what was cleared, for the audit trail:
{ "ok": true, "email": "jane@acme.co", "passkeysRemoved": 2, "devicesRevoked": 3 }
// 400 { "error": "SUDO_REQUIRED", "correlationId": "…" } if there is no step-up or it is older than 15 minutes
// 403 caller lacks manage_security · 404 user not found
// Containment only — drop the attacker's trusted browser, keep the user's factor
POST /api/v1/auth/security/devices/revoke/42
{ "ok": true, "email": "jane@acme.co", "revoked": 3 }
Reset Also Kills Trusted Devices
MFA_ADMIN_RESET with both counts.Step-Up Window Is Bounded
Session Length Is Yours To Set
5m to 24h. The value is validated before anything is saved: one outside that range, or not a valid duration, is refused with 400 and nothing is written (the refusal is audited as SESSION_TIMEOUT_REJECTED), so a typo can never lock every user out.Forcing MFA By Group
Whether a user is challenged for MFA at login is decided in one place — the resolver resolveMfaRequirement that every session-issuing path calls — from a three-state per-group policy: Group.mfaPolicy is inherit, require, or exempt (the legacy requiresMfa boolean is kept as a mirror of require). The decision is precedence-ordered, highest first:
- Privileged floor — always on, never exemptable. The
administratorrole, and anyone holding a floor permission (manage_security,manage_users,pay_invoice), always requires MFA — even inside an exempt group. This is what lets an admin hand out exemptions without opening a hole on the accounts that change policy, create accounts or mark invoices as paid. - An active group exemption wins next. A group set to
exemptlifts MFA for its non-privileged members. Exemptions are break-glass: each carries an optional expiry (mfaExemptUntil) and a reason (mfaExemptReason), and a lapsed window re-arms enforcement on its own — there is no sweep to run. - Any
requiregroup forces it. - Otherwise the org-wide default applies — the
mfaEnabledsetting, read live at login, which everyinheritgroup follows.
The Global Toggle Is A Live Default, Not A Bulk Flip
Turning global MFA on or off (POST /security with mfaEnabled, gated on manage_security + sudo) sets a genuine org-wide default that every inherit group follows live — it does not stamp a flag onto each group. A group created after you flip the toggle inherits the current default instead of silently opting out, and a require or exempt group keeps its own setting regardless. Granting or lifting an exemption is written to the audit trail as MFA_EXEMPTION_GRANTED / MFA_EXEMPTION_REVOKED.
WebAuthn Assertion Protocol
The identity service (identity-service) exposes WebAuthn challenge endpoints. Passkey credentials map to user records in the database:
// Step 1: Request a registration challenge
GET /api/v1/auth/webauthn/register/generate
Response: { "challenge": "4a7b2e9c...", "rp": { "name": "Zonitix Secure Access", "id": "<your instance's domain>" }, "user": { "id": "<base64url of the numeric user id>", ... }, ... }
// rp.name is the default label and can differ per instance.
// Step 2: Return the attestation to be verified and stored
POST /api/v1/auth/webauthn/register/verify
// Each challenge is held for 300 seconds and can be answered once; see Passkey Parameters below.
// WebAuthnCredential model (Sequelize, table: webauthn_credentials):
sequelize.define('WebAuthnCredential', {
id: { type: DataTypes.INTEGER, primaryKey: true, autoIncrement: true },
userId: { type: DataTypes.INTEGER, allowNull: false },
credentialID: { type: DataTypes.TEXT, allowNull: false, unique: true },
publicKey: { type: DataTypes.TEXT, allowNull: false },
counter: { type: DataTypes.BIGINT, allowNull: false, defaultValue: 0 },
transports: { type: DataTypes.JSONB, defaultValue: [] }
}, { tableName: 'webauthn_credentials', timestamps: true });
Passkey Parameters
The values the server uses in every passkey (WebAuthn) ceremony. None of them can be changed from the app. The first three are fixed for your instance when it is provisioned; the rest are the same on every instance.
| Parameter | Value and what it means |
|---|---|
| Relying-party ID | Your instance’s own sign-in hostname. It is written when the instance is provisioned and is not a setting in the app. A passkey is created for that hostname and the browser will only offer it there. |
| Relying-party name | Your company name followed by “Secure Access”. This is the label an authenticator shows when it asks to create the passkey. |
| Accepted origin | The HTTPS address of your instance’s sign-in page. Registration and every later use are verified against that origin and the relying-party ID; a response produced for any other origin is rejected. |
| Attestation | none: the server asks for no attestation statement. |
| User verification | required when a passkey is registered, when it is used as the second factor and when it is used to sign in without a password, so the authenticator must check a fingerprint, face or PIN each time. For a step-up the request asks for it as preferred. |
| Resident key (discoverable credential) | preferred. An authenticator that can store the passkey itself does so, which is what the password-free sign-in button needs. A security key that cannot store one still registers and works as a second factor, but cannot be used for password-free sign-in. |
| Already-registered passkeys | Sent as an exclude list at registration, so the same authenticator is not registered twice for one account. |
| Account shown on the authenticator | The person’s email address, as both the name and the display name. The user handle is the account’s numeric ID. |
| Algorithms offered at registration | The server does not choose them: it uses the defaults of its WebAuthn library, @simplewebauthn/server (13.3.3 on this revision), and passes no list of its own. Read the list from the registration options your own instance returns if you depend on it. |
| Prompt timeout | Also the library’s default, not a value the product sets, for registration and for each use. It is a hint to the browser, which may apply its own limits; the value is in the options your own instance returns. The server’s own deadline is the challenge lifetime below. |
| Challenge lifetime | 300 seconds. A challenge is deleted as soon as a response is checked against it, whatever the result, so each one can be answered once. |
| Where a challenge is kept | In Redis, with the 300-second expiry. If Redis cannot be reached the identity service keeps it in its own memory with the same deadline. |
| Signature counter | Stored with each passkey and replaced by the authenticator’s new value after every successful use. |
| What is stored per passkey | The credential ID, the public key, the counter and the transports the authenticator reported. No private key and no biometric data reaches the server. |
The endpoints are GET /api/v1/auth/webauthn/register/generate and POST /api/v1/auth/webauthn/register/verify to register, GET /api/v1/auth/webauthn/authenticate/generate and POST /api/v1/auth/webauthn/authenticate/verify for the second factor.