Identity & Sign-InMFA Enforcement
On this page0

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

A passkey is bound to your instance’s own web address, so it cannot be used on a look-alike site.

Emergency Recovery

Single-use recovery codes are issued at enrollment (10 per set) so a user who loses their only device is not locked out. Each code works exactly once, and someone who has lost everything can still be reset by an administrator.

Trusted Devices Do Not Apply To Privileged Accounts

Trust this device for 30 days lets an ordinary user skip the second factor on that browser. It is ignored for the privileged floor. 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. They are asked at every sign-in, even if they ticked the box.

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.

MethodPathPurposeAuth
POST/api/v1/auth/mfa/setup/initStage 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/confirmVerify 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/verifyComplete step-up at login with a TOTP code.MFA-challenge session token (isMfaSession)
POST/api/v1/auth/mfa/verify-recoveryComplete step-up at login with a single-use recovery code (consumes it).MFA-challenge session token (isMfaSession)
POST/api/v1/auth/mfa/recovery-codes/regenerateMint a fresh set of recovery codes, invalidating the previous set.Full session, mfaVerified === true
POST/api/v1/auth/security/mfa/reset/:userIdWipe 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/:userIdDrop 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

The issuer label shown in the authenticator is the literal 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 as LOGIN_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 issuer Zonitix. 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

A valid authenticator code is single-use. Presenting the same code a second time, even inside the window in which it would otherwise still verify, is refused with 400 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.

MethodPathPurposeAuth
POST/api/v1/auth/security/mfa/reset/:userIdDestructive: 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/:userIdContainment: 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

A full reset revokes trusted devices too, not just factors — otherwise a previously-trusted browser would keep skipping MFA the moment the user re-enrolls. Audited as MFA_ADMIN_RESET with both counts.

Step-Up Window Is Bounded

A step-up counts for 15 minutes. The server reads the window from a stored setting, sudoTimeoutMinutes, and holds any value between 1 and 60 minutes, but no screen and no API route writes that setting, so every instance uses 15 minutes. A missing or expired step-up is refused with "error": "SUDO_REQUIRED" in the response body. The status beside it is 400 on most routes, because the gateway turns a 401 from a service behind it into 400. It stays 401 on the routes the gateway passes through unchanged: removing a passkey, the diagnostics bundle, and in Security Lab starting an aggressive scan, changing a scan scope and archiving findings. Key on the error string, not the status.

Session Length Is Yours To Set

How long a sign-in stays valid is a policy you set in Security Settings — any duration from 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 administrator role, 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 exempt lifts 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 require group forces it.
  • Otherwise the org-wide default applies — the mfaEnabled setting, read live at login, which every inherit group 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.

ParameterValue and what it means
Relying-party IDYour 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 nameYour company name followed by “Secure Access”. This is the label an authenticator shows when it asks to create the passkey.
Accepted originThe 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.
Attestationnone: the server asks for no attestation statement.
User verificationrequired 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 passkeysSent as an exclude list at registration, so the same authenticator is not registered twice for one account.
Account shown on the authenticatorThe person’s email address, as both the name and the display name. The user handle is the account’s numeric ID.
Algorithms offered at registrationThe 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 timeoutAlso 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 lifetime300 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 keptIn 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 counterStored with each passkey and replaced by the authenticator’s new value after every successful use.
What is stored per passkeyThe 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.

Related reference