Skip to content

Multi-factor authentication

z4j supports time-based one-time-password (TOTP) authentication via any standard authenticator app -- Authy, 1Password, Aegis, Bitwarden, Google Authenticator, Microsoft Authenticator, etc. Enrollment is opt-in per user; operators can require it for admins or all users by flipping an env var. Single-use recovery codes cover the lost-phone case, and an admin-side CLI escape hatch covers the lost-phone-AND-lost-codes case.

z4j does NOT ship SMS or email-based MFA. Both are weak factors (SIM-swap, email-account-takeover) and provide a false sense of security; TOTP plus recovery codes covers the same ergonomics with a real second factor.

  • The TOTP secret is generated server-side (20 random bytes), encoded as base32 for the authenticator app, and never persists in plaintext. The brain stores it AES-GCM-encrypted with a key derived from Z4J_SECRET via HKDF-SHA256.
  • 10 recovery codes are minted at enrollment (controlled by Z4J_MFA_RECOVERY_CODE_COUNT, range 5..50). Format: XXXX-XXXX-XXXX, ~60 bits of entropy each. Hashed at rest with argon2id; single-use; consumed atomically on verification.
  • A successful TOTP / recovery verify stamps sessions.mfa_verified_at = NOW(). The sensitive-action gate accepts the caller for the next Z4J_MFA_VERIFICATION_TTL_SECONDS (default 60 minutes); after that, sensitive actions prompt for a fresh code.
  • The "Trust this device for 30 days" checkbox at verify time mints a server-side trust cookie. It is named __Host-z4j_mfa_trust outside the exact dev environment and z4j_mfa_trust in dev. Future logins from the same browser skip the second step until the cookie expires. The store is server-side, so the user can revoke any device from any session that has passed a fresh MFA check. Password change invalidates every trust cookie.
  1. Sign in to the dashboard as normal.
  2. Settings, then Security in the top-level Settings sidebar.
  3. Click Set up two-factor authentication.
  4. Scan the QR code with your authenticator app, or copy the base32 secret manually.
  5. Enter the 6-digit code your app shows. Submit.
  6. Download the 10 recovery codes shown next. Store them somewhere only you can access (password manager, printed copy in a safe). z4j shows them once and never again.

After enrollment, every sign-in prompts for the TOTP code before reaching the dashboard unless that browser presents a valid trusted-device cookie. Until the second factor is satisfied, the brain permits only whoami, logout, MFA-status, and MFA-verification routes; ignoring the dashboard prompt does not grant access to the rest of the API.

After password, the dashboard routes to the verify page. Enter either:

  • The 6-digit code from your authenticator app, OR
  • A recovery code (XXXX-XXXX-XXXX).

Optionally tick Trust this device for 30 days to skip the second step on this browser until the cookie expires.

Each code is single-use. Once consumed, it never verifies again. The dashboard shows the remaining count in Settings, Security; regenerate to invalidate the full set and mint a fresh 10.

Settings, Security lists every device you've trusted: a label synthesised from the User-Agent, last seen, expiry, and a Revoke button. The dashboard does not currently expose label editing, although the API supports it with PATCH /api/v1/auth/mfa/trusted-devices/{device_id}. The row matching your current cookie is flagged "this device". Revoking a trust row prevents that cookie from bypassing MFA on a later login. It does not invalidate a session that is already authenticated; change the password when existing sessions must also be terminated.

Changing your password automatically deletes every trust row, mirroring industry-standard behaviour (matches GitHub etc.) so the cookie cannot outlive the credential change.

For an MFA-enrolled user, the following actions require a recent MFA verification even within an authenticated browser session:

  • Changing your password; restarting an existing MFA enrollment; regenerating recovery codes; and trusting, renaming, or revoking a trusted device.
  • Minting a personal API key or deleting a project.
  • Creating, updating, resetting the password of, or deleting a user.
  • Minting or revoking an agent or invitation, and granting, updating, or revoking a project membership.
  • Creating, importing, updating, deleting, or testing a project notification channel, and creating, updating, or deleting a project notification default.
  • Creating or updating a destructive automation rule, resetting the circuit of a destructive rule, or re-enabling project automation. The rule gate also applies when an update touches a rule that was already destructive.

The routes in the first five bullets are cookie-session-only: a pure bearer request receives 401 rather than bypassing the step-up. Automation mutations are the exception. Their conditional gate accepts a sufficiently scoped bearer API key and requires fresh MFA only for a browser session.

If your last MFA verify is older than Z4J_MFA_VERIFICATION_TTL_SECONDS (default 60 min), the action returns 403 mfa_reverify_required. The dashboard sends you to the verification page and then returns you to the page you were on. It does not replay the rejected action; submit it again after verifying.

Settings, Security, click Disable two-factor authentication. Requires your current password AND a current TOTP code in the same form (not session-cached). On success, your MFA secret, recovery codes, and trusted devices are all cleared in one transaction.

Set env vars on the brain:

Terminal window
# Require MFA for every user with global is_admin=true.
Z4J_MFA_ENFORCE_FOR_ADMINS=true
# Stricter: require MFA for every user (admins and non-admins).
Z4J_MFA_ENFORCE_FOR_ALL=true
# How many days un-enrolled users have to enroll before their
# sessions are restricted to the enrollment flow. Default 7.
Z4J_MFA_ENROLLMENT_GRACE_DAYS=7

The grace clock starts per user, at the first login that observes the policy, so existing accounts get the full window on upgrade. Past the deadline, login still succeeds but the session is restricted to the MFA-enrollment endpoints; everything else answers 403 mfa_enrollment_required. The gate runs only for cookie sessions, not Bearer authentication. That exempts API-key-only service accounts, but it also means a human user who already holds an API key retains that key's full access after their browser session is blocked. Revoke personal API keys for users who miss the enrollment deadline when the policy must cut off all access. See MFA enforcement for the full semantics, the blocked-session experience, and the audit rows.

If a user loses both their phone and their recovery codes, an operator with shell access on the brain host runs:

Terminal window
z4j reset-mfa [email protected]

The CLI:

  1. Sets users.mfa_secret_encrypted = NULL and users.mfa_enrolled_at = NULL.
  2. Deletes every row in mfa_recovery_codes for the user.
  3. Deletes every row in trusted_devices for the user.
  4. Sets sessions.mfa_verified_at = NULL on their live sessions.
  5. Writes an audit row user.mfa_reset_by_admin. Its user_id is the user being reset. The operator appears only in metadata.operator, populated by Python's getpass.getuser() and therefore normally sourced from $LOGNAME/$USER; treat it as a self-asserted label and corroborate it with shell or sudo logs.

If the target has no MFA enrollment, the command is a no-op and writes no audit row. Otherwise, after the reset the user can sign in with their password and has no second factor at all. The fresh-MFA sensitive-action gate skips unenrolled users, so password-only sessions can perform sensitive actions until the user enrolls again. Treat that interval as an unprotected-account window. If global MFA enforcement applies and the user's existing grace deadline has already expired, the reset does not restart it; their session is instead restricted to enrollment until they enroll.

There is intentionally no REST API for this. An attacker who has only the dashboard cannot trigger it; only an operator with shell on the brain host can.

Setting Default Notes
Z4J_MFA_ENFORCE_FOR_ADMINS false Require enrollment for un-enrolled admins; past the grace window their sessions are restricted to the enrollment flow.
Z4J_MFA_ENFORCE_FOR_ALL false Same for every user.
Z4J_MFA_ENROLLMENT_GRACE_DAYS 7 Days an enforcement-targeted user has to enroll. Range 0..90.
Z4J_MFA_RECOVERY_CODE_COUNT 10 Codes minted at enrollment. Range 5..50.
Z4J_MFA_VERIFICATION_TTL_SECONDS 3600 How long a verify counts as fresh for the sensitive-action gate.
Z4J_MFA_REMEMBER_DEVICE_DAYS 30 Trust-cookie lifetime. Hard upper bound 90.
Z4J_MFA_VERIFICATION_RATE_PER_MIN 10 Per-IP cap per brain worker process, shared by verify, enroll-start, enroll-complete, and disable. PostgreSQL serving defaults to as many as four workers, so the effective process-fleet ceiling is multiplied by the worker count; SQLite uses one worker. Wrong codes are also bounded per account by Z4J_MFA_LOCKOUT_THRESHOLD (default 5) and Z4J_MFA_LOCKOUT_DURATION_SECONDS (default 900).

z4j's MFA defends against:

  • Credential leaks and password reuse.
  • Phishing of the password alone (without a real-time relay).
  • Stolen session cookies (sessions are HttpOnly + Secure + SameSite=Lax; sensitive actions still require fresh MFA).
  • Stolen trust cookies (server-side store + password-change invalidation; user can revoke any device from any session).

z4j's MFA does NOT defend against:

  • Real-time phishing relays (attacker-in-the-middle forwards the TOTP code). TOTP is vulnerable to AitM by design; WebAuthn / passkeys are the answer and are not currently shipped.

  • A brain operator with both DB access and Z4J_SECRET. The stored TOTP secret is encrypted with a key derived from Z4J_SECRET, so holding both recovers it.

    The audit chain does not sit on that same boundary, and it is worth not conflating them. Its rows are signed with Z4J_AUDIT_CHAIN_SECRET, a different key, and defeating the chain by rolling the log back needs neither key: write access to audit_log and audit_chain_state is enough. See threat model.

  • WebAuthn / passkeys (phishing-resistant second factor; additive to TOTP when added).
  • Hardware security keys (FIDO2; covered by the WebAuthn work).
  • Per-project enforcement policy. Enforcement is currently global per brain instance.