Rate limits (security)
Principle
Section titled “Principle”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.
Per-endpoint rationale
Section titled “Per-endpoint rationale”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.
POST /api/v1/auth/login
Section titled “POST /api/v1/auth/login”- 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_THRESHOLDandZ4J_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.
Password reset
Section titled “Password reset”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.
Invitations
Section titled “Invitations”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.
First-boot setup
Section titled “First-boot setup”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.
Bulk-write actions
Section titled “Bulk-write actions”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.
Storage and scaling
Section titled “Storage and scaling”Every built-in HTTP bucket is an in-process sliding-window counter. There is no PostgreSQL, Redis, or other shared HTTP rate-limit backend.
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.
Source-IP boundary and bypasses
Section titled “Source-IP boundary and bypasses”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.
429 responses
Section titled “429 responses”These rate limiters return 429 Too Many Requests without Retry-After,
X-RateLimit-Limit, X-RateLimit-Remaining, or X-RateLimit-Reset headers.
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.
Monitoring
Section titled “Monitoring”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.