Skip to content

Rate limiting

Included free on every install. Custom rules, changes to the sign-in protections, a tenant's own global limit, address allow and deny lists and the refusal history need a license with the rate-limit-pro feature. See pricing.

Rate limiting answers 429 Too Many Requests when a client sends more requests than a limit allows. It keeps one client from crowding out the others and slows down password guessing. Every install enforces the sign-in protections, and a super admin can turn on one global limit.

KindWhat it isWithout rate-limit-pro
GlobalOne rate and burst per client address, over every request to every tenant. Ships turned off at 50 per second with a burst of 100.A super admin turns it on and changes it.
ProtectionThe built-in limits on sign-in, password reset, magic links and MFA.Enforced at the shipped values.
CustomYour own rule for an endpoint pattern, tenant or role.Cannot be created or edited.
Tenant globalOne tenant's own rate and burst per client address, over every request to that tenant.Cannot be set.
Address listsPer-tenant allow and deny entries, each an address or a range.Cannot be added.

A request must pass every limit that matches it. There is no switch that turns limiting off. To relax a limit, change the rule.

If the license lapses, everything you already stored keeps being enforced: custom rules, the tenant limit and the address entries. You can still list and delete them, but not create or edit one. Refusals keep being counted, and reading them needs the license again.

You need a super admin token in TOKEN. The quickstart shows how to get one.

  1. See every limit in force:

    Terminal window
    curl http://localhost:3001/api/admin/rate-limits/overview \
    -H "Authorization: Bearer $TOKEN"

    The answer holds custom_licensed, the global rule ("enabled": false on a fresh install), the protections with their current and shipped values, and the engine limits on public routes. An admin who is not a super admin sees empty protections and engine lists.

  2. Watch the headers on any call:

    Terminal window
    curl -i -X POST http://localhost:3001/api/admin/auth/login \
    -H "Content-Type: application/json" -d '{}'

    A limited route answers with RateLimit-Limit, RateLimit-Remaining, RateLimit-Reset and RateLimit-Policy, and the same values as X-RateLimit-*.

  3. Turn on the global limit:

    Terminal window
    curl -X PUT http://localhost:3001/api/admin/rate-limits/global \
    -H "Authorization: Bearer $TOKEN" \
    -H "Content-Type: application/json" \
    -d '{"rate": 50, "burst": 100, "enabled": true}'

    The answer is the global rule with "enforced": true. Only a super admin may do this.

  4. With rate-limit-pro, add a rule that holds anonymous readers to 2 requests per second:

    Terminal window
    curl -X POST http://localhost:3001/api/admin/rate-limits \
    -H "Authorization: Bearer $TOKEN" \
    -H "Content-Type: application/json" \
    -d '{"endpoint": "GET /api/v1/*", "rate": 2, "burst": 10, "role": "anonymous"}'
    {
    "id": "0b9f6c1e-6f43-4c1a-9d0e-2a7c5f3e8b11",
    "endpoint": "GET /api/v1/*",
    "rate": 2,
    "burst": 10,
    "enabled": true,
    "kind": "custom",
    "role": "anonymous",
    "key_by": "ip",
    "created_at": "2026-10-01T09:00:00Z",
    "updated_at": "2026-10-01T09:00:00Z",
    "enforced": true
    }

    The answer is 201. A super admin's rule with no tenant_id applies to every tenant, so the answer carries none. Without the license it is 402 with payment_required.

  5. Open Settings > Rate limits in the admin console. The page shows the global limit, the protections, the limits on public routes and the custom rules.

The two sign-in routes allow 5 attempts per client address every 15 minutes:

  • POST /api/v1/auth/token
  • POST /api/admin/auth/login

These routes also carry a per-route limit of 5 per second with a burst of 10. Both apply, and the protection is the stricter. PUBLIC_RATE_LIMITS moves the per-route limit, not the protection.

Apart from these limits, every install refuses an account's password sign-in once it has 5 failures within 15 minutes, until that window ends. The refusal looks the same as a wrong password, and no setting changes it.

Other features add their own protections:

NameDefaultCounts
password-reset.email1 per 5 minutesReset mails per address
password-reset.ip10 per minuteReset requests per client address
magic-link.email3 per 15 minutesLink requests per address
magic-link.request-ip60 per 15 minutesLink requests per client address
magic-link.verify-ip120 per 15 minutesLink redemptions per client address
mfa.verify5 per 5 minutesCode checks per user
mfa.setup3 per hourAuthenticator setups per user

With rate-limit-pro, a super admin changes a protection with PUT /api/admin/rate-limits/protections and a body of name, requests (1 to 100000) and window_seconds (1 to 86400). POST /api/admin/rate-limits/protections/reset with name puts it back.

FieldMeaningDefault
endpoint* for every request, or METHOD /path. The method is * or one of GET, HEAD, POST, PUT, PATCH, DELETE, OPTIONS. A path segment may be a {name} placeholder matching one segment, and the last segment may be * for everything below it. Examples: GET /api/v1/content/{schema}/{id}, * /api/v1/*.required
rateRequests per second, more than zero.required
burstBucket size, at least 1.required
enabledWhether the rule applies.true
tenant_idA super admin may name a tenant, or leave it empty for every tenant. Anyone else's rule is filed under their own tenant.every tenant for a super admin, the caller's tenant for anyone else
roleEmpty for every caller, a role name to match callers holding it, or anonymous for callers who are not signed in.empty
key_byip counts each client address. user counts each signed-in account across every address it uses, and falls back to the address for a caller who is not signed in.ip

A change to a rule reaches every replica within about ten seconds.

With rate-limit-pro, each tenant can set one global limit of its own:

Terminal window
curl -X PUT http://localhost:3001/api/admin/rate-limits/tenant-global \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
-d '{"rate": 20, "burst": 40, "enabled": true}'

The tenant limit applies beside the install-wide global limit, and a request has to pass both, so the stricter one always wins. An install-wide limit that a super admin turns on or lowers later reaches every tenant at once. A tenant admin who saves a value looser than an enabled install-wide limit gets 403, because it would never take effect.

GET on the same path answers tenant_global (or null), the install_global limit beside it, and licensed. DELETE removes the tenant limit and stays free.

With rate-limit-pro, each tenant keeps allow and deny entries, each an address or a CIDR range, up to 500 per tenant:

Terminal window
curl -X POST http://localhost:3001/api/admin/rate-limits/ip-rules \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
-d '{"cidr": "203.0.113.0/24", "list": "deny", "note": "scraper"}'
  • A deny entry answers 403 with requests from this address are not allowed to the tenant's requests from that range, except a super admin's.
  • An allow entry wins over a deny entry. It also exempts the address from the tenant's own rules and its tenant limit, never from the install-wide global limit or the sign-in protections.
  • An entry that would deny the address you are calling from answers 409, so you cannot lock yourself out.

The three 409 refusals carry a code beside the message, as {"error": "...", "code": "rate_limit.self_lockout"}, so a client can tell them apart without reading the text: rate_limit.self_lockout, rate_limit.list_full and rate_limit.duplicate_entry.

GET /api/admin/rate-limits/ip-rules answers the rules with licensed and limits, as {"entries": {"limit": 500, "current": 12}}. DELETE /api/admin/rate-limits/ip-rules/{id} removes one and stays free.

Every install counts refused requests per tenant, per rule or address entry, and per minute, and keeps seven days of them. No address is stored. Each refusal a tenant's request meets is counted under that tenant, whichever limit refused it: its own rules and address entries, the sign-in protections, and the install-wide global limit as kind global. The limit shared through Redis is counted as kind global with the rule id shared. Reading them needs rate-limit-pro:

Terminal window
curl "http://localhost:3001/api/admin/rate-limits/history?hours=24" \
-H "Authorization: Bearer $TOKEN"

The answer carries hours, total_refused and minutes, newest first and at most 5,000, each with minute, rule_id, kind, endpoint and refused. hours goes from 1 to 168 and defaults to 24. The route reads the caller's own tenant. A super admin switches into a tenant to read that tenant's history.

A refused request answers 429 with Retry-After in seconds:

{ "error": "rate limit exceeded", "remaining": 0, "limit": 5, "reset_at": "2026-10-01T09:15:00Z" }

RateLimit-Policy is <burst>;w=1. The headers appear only on a request that some rule matched.

VariableWhat it doesDefault
TRUSTED_PROXIESComma-separated CIDR ranges allowed to set X-Forwarded-For. Empty ignores the header and counts the connecting address.empty
RATE_LIMIT_RPSA per-address ceiling for every request, set at start. 0 turns it off, which production refuses.0
RATE_LIMIT_BURSTBurst for that ceiling.twice RATE_LIMIT_RPS
RATE_LIMIT_PER_TENANTtrue counts that ceiling per tenant and address.false
RATE_LIMIT_BACKENDredis also counts the ceiling in Redis, shared by every replica. Needs RATE_LIMIT_RPS above 0.memory
RATE_LIMIT_REDIS_URLRedis for the shared count. Falls back to REDIS_URL, then redis://localhost:6379/0.unset
PUBLIC_RATE_LIMITSOverrides the per-route limits on public routes.unset

The shared Redis count is free on every install, and its burst defaults to RATE_LIMIT_RPS. Without it, each replica keeps its own counters. If you set LYEVE_PLUGINS, include rate-limit in it.

Every route needs the admin or super_admin role.

Rate limit routes
MethodPathPurposeAlso needs
GET/api/admin/rate-limitsList rules, paginated with limit and offset. ?kind=global, protection, custom or tenant_global narrows the list.
POST/api/admin/rate-limitsCreate a custom rule. 201.rate-limit-pro
GET/api/admin/rate-limits/overviewThe license state, the global limit, and for a super admin the protections and the per-route limits.
PUT/api/admin/rate-limits/globalSet the global limit: rate, burst, enabled.super admin
PUT/api/admin/rate-limits/protectionsChange a protection: name, requests, window_seconds.super admin and rate-limit-pro
POST/api/admin/rate-limits/protections/resetReturn a protection to its shipped value: name.super admin
GET/api/admin/rate-limits/statusLive counters for the caller's tenant, with client addresses redacted. A super admin may pass ?tenant=.
GET/api/admin/rate-limits/{id}Fetch one rule.
PUT/api/admin/rate-limits/{id}Update a custom rule.rate-limit-pro
DELETE/api/admin/rate-limits/{id}Delete a custom rule. 204.
GET/api/admin/rate-limits/tenant-globalThe tenant's own limit, the install-wide one and licensed.
PUT/api/admin/rate-limits/tenant-globalSet the tenant's limit: rate, burst, enabled.rate-limit-pro
DELETE/api/admin/rate-limits/tenant-globalRemove the tenant's limit.
GET/api/admin/rate-limits/ip-rulesThe tenant's address entries, licensed and limits.entries.
POST/api/admin/rate-limits/ip-rulesAdd cidr to list (allow or deny), with an optional note. 201.rate-limit-pro
DELETE/api/admin/rate-limits/ip-rules/{id}Remove an address entry.
GET/api/admin/rate-limits/historyRefused requests for your tenant. Query: hours.rate-limit-pro
StatusMessageCause
429rate limit exceededA limit refused the request.
402payment_requiredThe write needs rate-limit-pro.
400endpoint must be "*" or "METHOD /path", where a segment may be {name} and the last may be *The pattern is not one the matcher accepts.
Every other error
StatusMessageCause
400rate must be positive or burst must be positiveA value is zero or negative.
400key_by must be "ip" or "user"Unknown key_by.
400role must be 1 to 64 letters, digits or _ . : -The role name has other characters.
400requests must be between 1 and 100000A protection value is out of range.
400window_seconds must be between 1 and 86400A protection window is out of range.
403super_admin required to change the global limitAn admin tried to change the global limit.
403super_admin required to change an auth limitAn admin tried to change or reset a protection.
403super_admin required to name another tenantAn admin passed ?tenant= to the status route.
404rate-limit rule not found or no such protectionUnknown id or name.
409the global limit is changed through /api/admin/rate-limits/globalPUT or DELETE on the global limit through /{id}.
409a built-in protection is changed through /api/admin/rate-limits/protectionsPUT or DELETE on a protection through /{id}.
409a tenant's global limit is changed through /api/admin/rate-limits/tenant-globalPUT or DELETE on a tenant limit through /{id}.
400cidr must be an address or a CIDR range, list must be "allow" or "deny", note contains invalid charactersAn address entry is malformed.
400hours must be between 1 and 168The history range is out of bounds.
400the history is read for the request's own tenant?tenant= on the history route.
403a tenant limit may not be looser than the install-wide limitA tenant admin's limit would never take effect.
403requests from this address are not allowedA deny entry refused the request.
404address entry not found, tenant not foundUnknown entry, or a tenant the install does not hold.
409that range is already on the list, code rate_limit.duplicate_entryThe range is already listed.
409a tenant holds at most 500 address entries, code rate_limit.list_fullThe tenant is full.
409this entry would refuse the address you are calling from, code rate_limit.self_lockoutA deny entry that would lock you out.