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 15 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 z4j_mfa_trust cookie; future logins from the same browser skip the second step until the cookie expires. Server-side store, so the user can revoke any device from any session. Password change invalidates every trust cookie.
  1. Sign in to the dashboard as normal.
  2. Settings, Account, Security tab.
  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.

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: label (synthesised from the User-Agent, editable later), last seen, expiry, and a Revoke button. The row matching your current cookie is flagged “this device”. Revoking takes effect immediately on the server — the cookie continues to send but the brain refuses it.

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

Some actions require fresh MFA verification even within an authenticated session:

  • Changing your password
  • Minting an API key
  • Deleting a project
  • Regenerating recovery codes

If your last MFA verify is older than Z4J_MFA_VERIFICATION_TTL_SECONDS (default 15 min), the action returns 403 mfa_reverify_required and the dashboard prompts you for a fresh code, then retries.

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 login is
# blocked. Default 7.
Z4J_MFA_ENROLLMENT_GRACE_DAYS=7

The grace clock starts from when the policy was first observed for the user, so existing accounts get the full window on upgrade. Service accounts (API-key-only users that never sign in via session) are exempt; the gate fires on session logins, not on Bearer-authenticated requests.

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 user@example.com

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 attributed to the OS user running the CLI.

After the reset the user can sign in with their password as usual; the sensitive-action gate will treat their session as unverified until they enroll again.

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.

SettingDefaultNotes
Z4J_MFA_ENFORCE_FOR_ADMINSfalseBlock sign-in for un-enrolled admins past the grace window.
Z4J_MFA_ENFORCE_FOR_ALLfalseSame for every user.
Z4J_MFA_ENROLLMENT_GRACE_DAYS7Days an enforcement-targeted user has to enroll.
Z4J_MFA_RECOVERY_CODE_COUNT10Codes minted at enrollment. Range 5..50.
Z4J_MFA_VERIFICATION_TTL_SECONDS900How long a verify counts as fresh for the sensitive-action gate.
Z4J_MFA_REMEMBER_DEVICE_DAYS30Trust-cookie lifetime. Hard upper bound 90.
Z4J_MFA_VERIFICATION_RATE_PER_MIN10Per-IP cap on verify attempts per minute.

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. Same trust boundary as the audit chain.
  • 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.