Skip to content

Rate limits (security)

The built-in limits reduce accidental request floods and repeated attempts from one source IP. They are not a fleet-wide attack ceiling: every worker process has an independent in-memory counter, and rotating source IPs obtains fresh budgets.

See operations § rate limits for the wider operational reference.

All fixed limits below are hard-coded in z4j_brain.domain.ip_rate_limit. They are keyed by source IP, not by user or email address.

Five further buckets exist beyond the ones detailed here: agent connect, which covers the agent WebSocket and the long-poll HTTP routes, at 600 requests per 60 seconds; notification-channel test at 20 and channel import at 30; the OpenAPI schema and docs routes at 10; and one bucket shared by the MFA enroll-start, enroll-complete, verify and disable routes, capped by Z4J_MFA_VERIFICATION_RATE_PER_MIN.

  • IP limit: 20 requests per 60 seconds.
  • There is no per-email rate bucket. For an existing account, 10 cumulative failed logins since the last successful login cause a 15-minute lockout by default. Configure those values with Z4J_LOGIN_LOCKOUT_THRESHOLD and Z4J_LOGIN_LOCKOUT_DURATION_SECONDS.
  • Attempts against an unknown email have only the per-IP limit. An attacker who knows an existing address can keep extending its lockout with further failed attempts.

Argon2 adds configurable CPU and memory cost to each password verification. Benchmark that cost on your hardware rather than assuming a fixed duration.

POST /api/v1/auth/password-reset/request and POST /api/v1/auth/password-reset/confirm share one budget of 10 requests per 60 seconds per IP.

There is no per-email or per-recipient limit. Each request for a known address mints a fresh token and queues another delivery attempt when an email channel is available. Use a mail-provider or reverse-proxy recipient limit if inbox flooding is in your threat model.

The request endpoint returns the same {"accepted": true} response for known and unknown addresses so its body does not disclose account existence.

POST /api/v1/invitations/preview and POST /api/v1/invitations/accept share one budget of 30 requests per 60 seconds per IP.

Invitation tokens contain 256 bits of entropy (32 random bytes) and are stored as keyed HMAC-SHA256 digests. The rate limit is defence in depth rather than the primary protection against guessing a valid token.

POST /api/v1/setup/complete has an in-memory limit of 5 requests per 15 minutes per IP. GET /setup is not rate-limited.

The setup service also checks recent setup.* audit rows over a 15-minute window. The default durable budget is 30 attempts per IP (Z4J_FIRST_BOOT_ATTEMPTS_PER_IP) and 8 times that value globally. This second budget is shared through the database and survives worker restarts. The setup token itself contains 256 bits of entropy and is single-use.

These operations share one budget of 10 requests per 60 seconds per source IP:

  • Task bulk-delete
  • Command bulk-retry and purge-queue
  • Creation of a durable bulk-retry request
  • Schedule trigger-now and schedule resync
  • Project notification-channel creation and default-subscription creation

The bucket is not per user and the operations do not have separate budgets. Ten operators behind one NAT therefore share the same ten accepted requests.

Every built-in HTTP bucket is an in-process sliding-window counter. There is no PostgreSQL, Redis, or other shared HTTP rate-limit backend.

Each limiter holds at most 10,000 source-IP keys per process. When every retained key is still active inside the window, a request from an IP not yet in the table is refused with 429 without being recorded, so a flood from many distinct addresses (including spoofed X-Forwarded-For values behind a trusted proxy) can lock out new legitimate clients until a key ages out. A trusted proxy that forwards the inbound header verbatim lets one peer fill the table by itself; a proxy that strips the inbound header and appends the real peer, as the threat model asks, gives a forging peer exactly one key.

The unit of enforcement is one worker process, not one replica. A standalone PostgreSQL brain defaults to min(4, cpu_count) uvicorn workers; SQLite and the embedded-scheduler topology force a single worker. With N replicas of M workers, requests spread across the fleet can receive as much as N x M times each stated budget. Use z4j serve --workers=1 when one-process enforcement is required, or enforce a shared limit at the reverse proxy.

There is no trusted-IP exemption list: localhost is limited at the same rates as any other source. X-Forwarded-For is honoured only when the immediate peer belongs to Z4J_TRUSTED_PROXIES.

The absence of an exemption is not the absence of bypasses. Source-IP rotation gets a fresh bucket, and requests distributed across worker processes multiply the effective budget. Treat the built-in counters as protection from one noisy client, not as the only credential-stuffing or denial-of-service control. Authentication does not increase the shared bulk-write budget.

These rate limiters return 429 Too Many Requests with a Retry-After header and without X-RateLimit-Limit, X-RateLimit-Remaining, or X-RateLimit-Reset headers. The header carries the wait in whole seconds, and the response body names the same wait and the bucket: too many requests; retry in <n> seconds (<bucket>), where the delay comes from the oldest counted hit and never exceeds the window. Wait for the complete bucket window after the oldest accepted request: 60 seconds for the login, password-reset, invitation, and bulk buckets; 15 minutes for setup.

A rejected request is not appended to the sliding window, so retrying early does not extend the penalty. It simply continues returning 429 until an accepted hit ages out.

The built-in limiter exports no denial counter and emits no dedicated denial log entry. In particular, there is no z4j_rate_limit_denied_total{endpoint} metric. Observe and alert on HTTP 429s at the reverse proxy, or explicitly enable and collect access logs.