Skip to content

Authentication

Type Who Lifetime Where used
Session cookie Users (dashboard) Normally 7 days absolute / 30 min idle; remembered sessions are 30 days and bypass idle expiry Browser
API key Users (CLI, scripts) Optional expiry; revocable Authorization: Bearer z4k_...
Agent token + HMAC secret Agents No expiry; revocable WebSocket handshake (token) + frame signing (HMAC secret)
POST /api/v1/auth/login
Content-Type: application/json
{ "email": "...", "password": "..." }

Response: {"user": UserPublic, "mfa_required": bool, "mfa_enrollment_required": bool, "mfa_enrollment_deadline": timestamp or null}. When mfa_required is true the new session may call only POST /api/v1/auth/mfa/verify, GET /api/v1/auth/me, GET /api/v1/auth/mfa/status and POST /api/v1/auth/logout until the second factor passes. mfa_enrollment_deadline is set only when mfa_enrollment_required is true.

In production the response sets __Host-z4j_session (HttpOnly, Secure, SameSite=Lax) plus a JavaScript-readable __Host-z4j_csrf cookie (Secure, SameSite=Strict). The session cookie's SameSite follows Z4J_SESSION_COOKIE_SAMESITE (lax by default, or strict); the CSRF cookie is always Strict. Development uses the unprefixed names and permits insecure localhost cookies. A normal session is rejected after Z4J_SESSION_ABSOLUTE_LIFETIME_SECONDS (default 7 days) or Z4J_SESSION_IDLE_TIMEOUT_SECONDS (default 30 minutes), whichever comes first. When login sends "remember_me": true, the session instead uses Z4J_SESSION_REMEMBER_ME_LIFETIME_SECONDS (default 30 days) and bypasses idle expiry.

POST /api/v1/auth/logout

Requires the CSRF token, invalidates the session server-side, clears both session cookies, and returns 204 No Content.

Every route under /api/v1/auth/, including GET /api/v1/auth/me and the MFA routes, is session-cookie only: the auth tag is on the API-key deny list, so a key is refused with 403 forbidden whatever its scopes.

Endpoint Purpose
GET /api/v1/auth/me Current user + project memberships.
PATCH /api/v1/auth/me Update display name etc.; CSRF-protected.
POST /api/v1/auth/change-password Authenticated password change; CSRF-protected and requires fresh MFA.
GET /api/v1/auth/sessions List the user's active sessions.
POST /api/v1/auth/sessions/{session_id}/revoke Revoke one of the caller's sessions; CSRF-protected.
POST /api/v1/auth/sessions/revoke-others Revoke every session the caller holds except the current one; CSRF-protected.
GET /api/v1/auth/policy The active password policy, for client-side pre-validation.

All ten routes live under /api/v1/auth/mfa. "Fresh MFA" means a browser session whose second factor was verified within Z4J_MFA_VERIFICATION_TTL_SECONDS; like every /auth/* route, these refuse an API key (403, see above). The MFA-verify throttle is one per-IP bucket shared by enroll-start, enroll-complete, verify and disable, capped by Z4J_MFA_VERIFICATION_RATE_PER_MIN (default 10 per minute).

Endpoint Purpose Requires
POST /api/v1/auth/mfa/enroll-start Start (or restart) enrollment: mints a TOTP secret and returns secret_base32 plus the otpauth provisioning_url. Session cookie, CSRF, MFA-verify throttle
POST /api/v1/auth/mfa/enroll-complete Confirm the pending enrollment with a code, activate MFA and return the recovery_codes (shown once). Session cookie, CSRF, MFA-verify throttle
POST /api/v1/auth/mfa/verify Verify a TOTP or recovery code and stamp the session as MFA-verified. Session cookie, CSRF, MFA-verify throttle
POST /api/v1/auth/mfa/disable Disable MFA; the body carries the current password and a current TOTP code, checked on every call. CSRF, MFA-verify throttle
POST /api/v1/auth/mfa/recovery-codes/regenerate Replace every recovery code with a fresh set. Fresh MFA, CSRF
GET /api/v1/auth/mfa/status The caller's MFA state: enrolled, enrolled_at, remaining_recovery_codes, enforcement fields. Authenticated user
GET /api/v1/auth/mfa/trusted-devices List the caller's trusted devices. Authenticated user
POST /api/v1/auth/mfa/trusted-devices Trust the current browser without logging out; returns 201. Fresh MFA, CSRF
POST /api/v1/auth/mfa/trusted-devices/{device_id}/revoke Revoke one of the caller's trusted devices (clears the cookie if it matches); returns 204. Fresh MFA, CSRF
PATCH /api/v1/auth/mfa/trusted-devices/{device_id} Rename a trusted device. Fresh MFA, CSRF
POST /api/v1/auth/password-reset/request # public; body: {"email": "..."}
POST /api/v1/auth/password-reset/confirm # public; body: {"token": "...", "new_password": "..."}

The reset token TTL is 30 minutes. The request endpoint never reveals whether the email exists -- it returns success either way.

Create from Dashboard, Settings, API Keys. Tokens begin with z4k_ and are shown once at creation.

Terminal window
curl -H "Authorization: Bearer z4k_..." \
https://z4j.example.com/api/v1/projects/billing-prod/tasks

Management endpoints:

Endpoint Purpose
GET /api/v1/api-keys List the caller's active keys (revoked keys are omitted): id, name, prefix, scopes, project binding, expiry, creation and last-use fields; revoked_at and revoked_reason are present but always null. Plaintext is never re-emitted.
POST /api/v1/api-keys Mint a new key; CSRF-protected and requires fresh MFA. Returns 201 with the plaintext once. Accepts an optional allowed_cidrs list.
PATCH /api/v1/api-keys/{key_id} Replace the key's allowed_cidrs; CSRF-protected and requires fresh MFA. null or [] clears it. Only the caller's own active keys. Returns the key.
DELETE /api/v1/api-keys/{key_id} Revoke a key; CSRF-protected. Returns 204.
GET /api/v1/api-keys/scopes Catalog of valid scopes.

API keys can be project-scoped (bound to one project) or unscoped. A project-scoped key filters GET /projects to its bound project and is rejected on a different project's slug. This is not a complete method-level boundary: for example, a global-admin-owned project-scoped key with projects:write can still create a project because /projects is on the non-slug allow-list.

A key can carry allowed_cidrs: up to 32 IPv4 or IPv6 CIDRs (a bare address means one host), stored in canonical form, shown in the key list and editable through PATCH without rotating the token. When set, a request with that key must come from one of them, in addition to the brain-wide Z4J_API_IP_ALLOWLIST if the operator configured one; both lists must admit the request. The address matched is the one resolved behind Z4J_TRUSTED_PROXIES, so a forged X-Forwarded-For from an undeclared source is ignored. Loopback is not implicitly allowed.

The list is consulted after the key authenticates, so the audit row names the key, and before any scope or project check, so a caller outside the list learns nothing about the key's reach. A refused request gets 403 with the stable body:

{"error": "ip_denied", "message": "source address is not allowed for this surface", "details": {"surface": "api"}}

The body is the same whatever was presented; the resolved address and the key id go to the auth.ip_denied audit row, which carries api_key_id and metadata.reason (global_allowlist or key_allowed_cidrs). Minting a key writes an api_key.created row and changing its allowed_cidrs an api_key.updated row, so the list a key was refused under is itself on the trail. The z4j_auth_ip_denied_total{surface="api"} counter climbs by one. A refused request does not update the key's last-used fields. Note the trade-off this ordering buys: a caller outside the list still sees 401 for an unknown key and 403 for a valid one.

Minted via the agents API -- POST /api/v1/projects/{slug}/agents returns both a bearer token (WebSocket handshake) and an hmac_secret (per-frame signing). Agents refuse to start without the HMAC secret.

Rate limits on auth (per IP and per brain process, 1-minute window)

Section titled “Rate limits on auth (per IP and per brain process, 1-minute window)”
Endpoint Cap
POST /auth/login 20 hits / minute
POST /auth/password-reset/request and /confirm 10 hits / minute (combined bucket)
POST /invitations/preview and POST /invitations/accept 30 hits / minute (combined bucket)

Per-account lockout runs in parallel with the per-IP login cap: repeated wrong passwords on a single account lock that account for a cooling-off window regardless of source IP. See security rate limits for the design rationale.

The three IP buckets are held in process memory. With multiple serve workers, each worker has its own bucket, so the aggregate requests accepted by the deployment can exceed the table's nominal caps.

Multi-factor authentication is built in: a TOTP second factor from any standard authenticator app, single-use recovery codes (ten by default, Z4J_MFA_RECOVERY_CODE_COUNT), and an opt-in remember-this-device cookie. Sensitive actions require a fresh second-factor step-up. Org-wide enforcement is available and off by default, with a per-user grace window. See multi-factor authentication and MFA enforcement.

z4j has no SSO or OAuth2 login. Put z4j behind an authenticated reverse proxy (oauth2-proxy, Cloudflare Access, Pomerium) so the SSO layer authenticates the user before they reach z4j's login form.