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 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_trustcookie; 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.
Enrolling
Section titled “Enrolling”- Sign in to the dashboard as normal.
- Settings, Account, Security tab.
- 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.
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: 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.
Sensitive-action gate
Section titled “Sensitive-action gate”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.
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 login is# blocked. Default 7.Z4J_MFA_ENROLLMENT_GRACE_DAYS=7The 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.
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:
z4j reset-mfa user@example.comThe 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_adminattributed 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.
Settings reference
Section titled “Settings reference”| Setting | Default | Notes |
|---|---|---|
Z4J_MFA_ENFORCE_FOR_ADMINS | false | Block sign-in for un-enrolled admins past the grace window. |
Z4J_MFA_ENFORCE_FOR_ALL | false | Same for every user. |
Z4J_MFA_ENROLLMENT_GRACE_DAYS | 7 | Days an enforcement-targeted user has to enroll. |
Z4J_MFA_RECOVERY_CODE_COUNT | 10 | Codes minted at enrollment. Range 5..50. |
Z4J_MFA_VERIFICATION_TTL_SECONDS | 900 | 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 on verify attempts per minute. |
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. Same trust boundary as the audit chain.
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.