Skip to content

Rate Limiting

Every CRUD route is rate-limited by default – not just models that opt in. A model with no rate_limit block still gets the manifest’s global default (100 requests/60s per IP, unless changed in kirak.json). Limits are enforced before access control or hooks run.


{
"rate_limit": {
"enabled": true,
"default_max_requests": 100,
"default_window_seconds": 60,
"trusted_proxy_ips": []
}
}
Key Type Default Description
enabled boolean true Global kill switch. false disables rate limiting everywhere – per-model CRUD limits and the auth endpoint limits alike.
default_max_requests integer 100 Applied to any model with no rate_limit block.
default_window_seconds integer 60 Window for the default limit.
trusted_proxy_ips array of strings [] IPs allowed to set X-Forwarded-For/X-Real-IP – see Client IP resolution below.

Add a rate_limit block to any model to override the global default for that model. Two shapes are supported:

Flat (same limit for every operation on the model):

{
"products": {
"table": "products",
"schema": { "..." },
"rate_limit": {
"max_requests": 60,
"window_seconds": 60,
"per": "user"
}
}
}

Per-operation (different limits per CRUD operation, with an optional default fallback for operations not listed):

{
"products": {
"rate_limit": {
"default": { "max_requests": 100, "window_seconds": 60, "per": "ip" },
"create": { "max_requests": 10, "window_seconds": 60, "per": "user" },
"destroy": { "max_requests": 5, "window_seconds": 3600, "per": "user" }
}
}
}

Any of fetch, search, count, exists, create, update, upsert, delete, destroy, restore can have its own sub-config. A rate_limit block only overrides the operations it lists: an operation with no entry and no default still inherits the global kirak.json default, the same as a model with no rate_limit block at all.

Key Type Required Description
enabled boolean No false disables rate limiting for this model entirely. Absent or true leaves limiting active.
max_requests integer Yes* Maximum requests allowed within the window. *Not required when enabled: false.
window_seconds integer Yes* Rolling window duration in seconds. *Not required when enabled: false.
per string No Counter scope: "ip" (default), "user", or "model".

To opt a model out of rate limiting entirely, set enabled: false:

{
"internal_jobs": {
"rate_limit": { "enabled": false }
}
}

Omitting rate_limit, setting it to {} or to {"enabled": true} all fall back to the global default – none of them disables limiting.

The two shapes cannot be mixed, and a flat block needs max_requests: {"max_requests": 10, "create": {...}} and {"per": "user"} are rejected when the models load, so a limit is never silently ignored. In a per-operation block every entry needs max_requests.


One counter per remote IP address, per model, per operation. Best for public-facing endpoints.

  • Key: crud:{model_name}:{operation}:ip:{ip_address}

One counter per authenticated user, per model, per operation. Best for authenticated endpoints.

  • Key: crud:{model_name}:{operation}:user:{user_id} (from JWT sub claim)
  • When the request is unauthenticated, it still uses a user:-keyed bucket, with user_id set to anon:{ip_address} – it does not merge into the per: "ip" counter.

One global counter shared across all users and IPs, per model, per operation.

  • Key: crud:{model_name}:{operation}

By default, the ip scope uses the raw connecting socket address (request.client.host) – headers like X-Forwarded-For are ignored, so a client can’t spoof its way to a fresh counter just by setting one.

Behind a reverse proxy or load balancer, add its IP to trusted_proxy_ips in kirak.json to have Kirak trust that specific hop’s forwarding headers instead:

{ "rate_limit": { "trusted_proxy_ips": ["10.0.0.5"] } }

When the direct connecting IP matches an entry in trusted_proxy_ips, Kirak reads X-Forwarded-For (the leftmost entry) or falls back to X-Real-IP. When it doesn’t match, the direct socket IP is used regardless of what headers are present. This same resolution logic also applies to the auth endpoint limits (login, register, etc. – see below), not just per-model CRUD limits.


Rate limit counters are stored in:

  • Redis (if KIRAK_REDIS_URL is set) – sliding-window algorithm using Redis sorted sets, enforced atomically in a Lua script (ZREMRANGEBYSCORE + ZCARD + ZADD + EXPIRE). More accurate, auto-expires, gives a precise Retry-After.
  • Database (fallback) – fixed-window using the kirak_rate_limits table, with SELECT ... FOR UPDATE row locking so concurrent requests against the same key can’t both read the same count and both squeak past the limit.

Redis is strongly recommended for rate limiting in production. Fixed-window counters in the database can allow brief bursts at window boundaries.

Both backends fail open: if Redis is unreachable, or the kirak_rate_limits table doesn’t exist, the request is allowed through and a warning is logged – rate limiting never itself becomes an outage.


The request is rejected with HTTP 429 before any operation runs:

{
"statusCode": 429,
"status": "error",
"error": "RATE_LIMIT_EXCEEDED",
"message": "Too many requests. Please try again later.",
"data": null
}

Response headers:

HTTP/1.1 429 Too Many Requests
Retry-After: 42

Retry-After is the number of seconds until the oldest request expires from the window.

Every CRUD response that passes the rate limit check also carries quota headers, so a client can back off before it actually hits the limit:

X-RateLimit-Limit: 100
X-RateLimit-Remaining: 87
X-RateLimit-Reset: 1735689600

X-RateLimit-Reset is a Unix timestamp for when the current window ends.


The same limiter backs a public dependency for routes you add yourself outside the auto-generated CRUD routes:

from fastapi import Depends, Request
from kirak.core.utils.crud_rate_limit import rate_limit_dep
@app.post("/search")
async def search(
request: Request,
_: None = Depends(rate_limit_dep("search:global", 10, 60)),
):
...

rate_limit_dep(key, max_requests, window_seconds) returns a FastAPI dependency that raises HTTPException(429) when the limit is exceeded, using the same Redis/DB backend selection and fail-open behavior as CRUD routes. Pick a key that won’t collide with the crud:* keyspace used internally.


Three admin-only (admin, system or superadmin role) endpoints under /admin let you inspect and clear rate limit counters at runtime, against whichever backend (Redis or DB) is active:

Method Path Description
GET /admin/rate-limits List current counters. Query params: prefix (filter by key prefix), limit (default 100, max 1000).
GET /admin/rate-limits/{key} Get the counter for one specific key.
DELETE /admin/rate-limits/{key} Reset (delete) the counter for one key – the next request starts a fresh window.

{key} is the full internal key, e.g. crud:posts:fetch:ip:192.168.1.1 – exactly as returned by the list endpoint (the kirak:rl: Redis prefix is stripped for display and doesn’t need to be included; the crud: part does). Useful for unblocking a legitimately rate-limited user, or checking why a specific key is being throttled, without needing direct Redis/DB access.


Public search endpoint – strict IP limit

Section titled “Public search endpoint – strict IP limit”
{
"products": {
"rate_limit": {
"max_requests": 30,
"window_seconds": 60,
"per": "ip"
}
}
}

Authenticated users – generous per-user limit

Section titled “Authenticated users – generous per-user limit”
{
"orders": {
"rate_limit": {
"max_requests": 200,
"window_seconds": 60,
"per": "user"
}
}
}

Expensive export endpoint – global model limit

Section titled “Expensive export endpoint – global model limit”
{
"exports": {
"rate_limit": {
"max_requests": 10,
"window_seconds": 3600,
"per": "model"
}
}
}

Auth endpoints use the same default_max_requests and default_window_seconds from kirak.json as all other routes. When no global defaults are set, they fall back to 100 requests per 60 seconds.

Endpoint Counter scope
POST /auth/login per IP, per email (two separate counters)
POST /auth/register per IP
GET /auth/verify-email per IP
POST /auth/request-reset-password per email
POST /auth/request-otp per phone
POST /auth/verify-otp per phone
POST /auth/{provider}/mobile per IP
GET /auth/{provider}/callback per IP
POST /auth/setup-mfa per IP
POST /auth/verify-mfa per IP
POST /auth/disable-mfa per IP

POST /auth/apple/callback (Apple’s form-POST callback, separate from the generic GET /auth/{provider}/callback route) shares the same social:callback:ip:* counter as the row above – it isn’t a distinct limit.

To tighten auth limits for production, set lower global defaults in kirak.json:

{
"rate_limit": {
"default_max_requests": 10,
"default_window_seconds": 60
}
}

The global rate_limit.enabled: false flag still disables all limits (CRUD and auth) and is the only override that applies uniformly regardless of the default values.