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.
How it works
Section titled “How it works”- 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_SECRETvia 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 nextZ4J_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_trustoutside the exactdevenvironment andz4j_mfa_trustindev. 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.
Enrolling
Section titled “Enrolling”- Sign in to the dashboard as normal.
- Settings, then Security in the top-level Settings sidebar.
- Click Set up two-factor authentication.
- Scan the QR code with your authenticator app, or copy the base32 secret manually.
- Enter the 6-digit code your app shows. Submit.
- 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.
Signing in
Section titled “Signing in”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.
Recovery codes
Section titled “Recovery codes”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.
Trusted devices
Section titled “Trusted devices”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.
Sensitive-action gate
Section titled “Sensitive-action gate”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.
Disabling
Section titled “Disabling”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.
Operator: enforce for admins / all users
Section titled “Operator: enforce for admins / all users”Set env vars on the brain:
# 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=7The 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.
Operator: lost-phone recovery
Section titled “Operator: lost-phone recovery”If a user loses both their phone and their recovery codes, an operator with shell access on the brain host runs:
The CLI:
- Sets
users.mfa_secret_encrypted = NULLandusers.mfa_enrolled_at = NULL. - Deletes every row in
mfa_recovery_codesfor the user. - Deletes every row in
trusted_devicesfor the user. - Sets
sessions.mfa_verified_at = NULLon their live sessions. - Writes an audit row
user.mfa_reset_by_admin. Itsuser_idis the user being reset. The operator appears only inmetadata.operator, populated by Python'sgetpass.getuser()and therefore normally sourced from$LOGNAME/$USER; treat it as a self-asserted label and corroborate it with shell orsudologs.
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.
Settings reference
Section titled “Settings reference”| 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). |
Threat model
Section titled “Threat model”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 fromZ4J_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 toaudit_logandaudit_chain_stateis enough. See threat model.
Not currently shipped
Section titled “Not currently shipped”- 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.