Skip to content

MFA enforcement

z4j can require users to enroll in TOTP MFA. Enforcement is opt-in and off by default: with the flags at their defaults, nothing changes for anyone, on any upgrade. Flipping a flag starts a per-user grace window; a targeted user who has not enrolled by the deadline keeps their password login but loses everything else until they enroll.

Variable Default Description
Z4J_MFA_ENFORCE_FOR_ADMINS false Require enrollment for every user with the global is_admin bit.
Z4J_MFA_ENFORCE_FOR_ALL false Require enrollment for every user. Stricter superset of the admins-only flag.
Z4J_MFA_ENROLLMENT_GRACE_DAYS 7 Days a targeted user has to enroll once their grace clock starts. Range 0..90; 0 restricts targeted users from their first post-policy login onward.

Z4J_MFA_ENFORCE_FOR_ADMINS targets the global admin bit only. Project-level admin memberships do not count: project roles are per-project authorization a user can gain and lose many times a day, and anchoring an account-level grace clock to them would make the deadline flap. Enforcement is global per brain instance; there is no per-project policy.

A targeted user who already has MFA enrolled is unaffected. The policy only has something to demand from targeted users without MFA.

The grace clock does not start when you flip the flag. It starts per user, at the first login that observes the policy applying to that user, when the brain stamps a one-time anchor (users.mfa_enforcement_started_at). The deadline is anchor + Z4J_MFA_ENROLLMENT_GRACE_DAYS.

Consequences of that design:

  • Existing accounts get the full window. Enabling enforcement on a long-running install never instantly locks anyone out; each user’s clock starts at their next sign-in.
  • A user who never signs in never starts their clock, and is never blocked. (They also hold no session, so there is nothing to restrict.)
  • The anchor is one-way. It is stamped once and never cleared, so a user cannot reset their grace window by enrolling, disabling MFA, and waiting for a fresh clock. A user who disables MFA after their original deadline has passed is restricted again immediately at their next request.
  • Concurrent first logins race safely: the database arbitrates, exactly one login stamps the anchor.

Within the window the user can sign in and work normally; the login response and MFA status endpoint carry the deadline so a client can surface an “enroll by” banner.

Login past the deadline still succeeds. Refusing the password outright would make enrollment impossible, because enrollment itself requires an authenticated session. Instead, the session is restricted to an enrollment-only allowlist:

GET /api/v1/auth/me
POST /api/v1/auth/logout
GET /api/v1/auth/mfa/status
POST /api/v1/auth/mfa/enroll-start
POST /api/v1/auth/mfa/enroll-complete

Every other authenticated call answers 403 with the stable error code mfa_enrollment_required and the deadline in the error details. In practice: the user signs in, goes to Settings, Account, Security, completes TOTP enrollment, and the restriction lifts on their very next request. Other dashboard panels will fail to load data until then.

The gate is evaluated on every cookie-session request, so the policy is live in both directions: it engages mid-session the moment a deadline passes, and it lifts immediately after enrollment (or after the operator relaxes the policy), with no re-login needed.

Two responses carry the enforcement state so clients can render it:

  • POST /api/v1/auth/login returns mfa_enrollment_required (bool) and mfa_enrollment_deadline (timestamp or null) alongside the normal login payload.
  • GET /api/v1/auth/mfa/status returns enrollment_required and enrollment_deadline. This endpoint stays reachable even for a blocked session, so an enrollment page can render the countdown or the lockout notice.

A null deadline with required: true means the policy targets the user but their grace clock has not started yet (they have not logged in under the policy).

Bearer (API-key) callers are deliberately not evaluated by this policy. API keys are a separate credential class with explicit scopes and no second-factor ceremony; blocking them would break automation the moment an operator’s grace window expired, without improving the security of the interactive account.

The exemption does not leak: minting a new API key is a session-gated action, so a blocked user cannot escape the restriction by creating a key. Keys minted before the deadline keep working, which is the intended behavior for service integrations.

Enforcement writes two HMAC-chained audit actions:

Action When
auth.mfa_enforcement_grace_started Exactly once per user, at the login that stamps the grace anchor. Metadata records which policy flag applied, the grace days, and the computed deadline - the “when was this account first told” answer a compliance reviewer asks for.
auth.mfa_enforcement_blocked On each login that lands past the deadline.

Per-request 403s are intentionally not audited; a blocked user’s browser retrying a dashboard poll would otherwise flood the chained log.

  1. Announce, then set Z4J_MFA_ENFORCE_FOR_ADMINS=true and restart the brain. Admins see the deadline at next login.
  2. Watch for auth.mfa_enforcement_grace_started rows to confirm the policy is reaching the right accounts.
  3. Once admins are enrolled, escalate to Z4J_MFA_ENFORCE_FOR_ALL=true with a fresh announcement.

If a user loses their phone and recovery codes after enrolling, the z4j reset-mfa CLI (lost-phone recovery) clears their MFA state. Under an active enforcement policy the reset does not grant a new grace window (the anchor is one-way), so a reset user past their deadline signs in straight into the enrollment-only session and re-enrolls, which is exactly the flow they need.