Authentication
Three credential types
Section titled “Three credential types”| 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) |
Login (session)
Section titled “Login (session)”POST /api/v1/auth/loginContent-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.
Logout
Section titled “Logout”POST /api/v1/auth/logoutRequires the CSRF token, invalidates the session server-side, clears both
session cookies, and returns 204 No Content.
Other session endpoints
Section titled “Other session endpoints”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. |
MFA endpoints
Section titled “MFA endpoints”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 |
Password reset
Section titled “Password reset”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.
API keys
Section titled “API keys”Create from Dashboard, Settings, API Keys. Tokens begin with z4k_ and are shown once at creation.
curl -H "Authorization: Bearer z4k_..." \ https://z4j.example.com/api/v1/projects/billing-prod/tasksManagement 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.
Source-address restrictions
Section titled “Source-address restrictions”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.
Agent tokens
Section titled “Agent tokens”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.
SSO / OAuth2
Section titled “SSO / OAuth2”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.