How-to › Make calls that survive failure

How to rate limit by API key and tenant, not client IP#

Enforce one limit per API key and a larger one per tenant, so a noisy key cannot spend a customer's whole allowance and one customer cannot spend yours.

Audience
API producer
Level
intermediate
Topic
Set and respect rate limits
Languages
TypeScript and JavaScript
Verified

One customer runs a backfill and every other customer starts being refused. The limiter is keyed on the client address. The backfill runs from a data center behind a single egress address, so one script filled a bucket shared with the office next door. Meanwhile a customer with a hundred workers holds a hundred separate allowances.

What you get

You will end up with a limiter keyed on the authenticated principal, enforcing a per-key budget inside a larger per-tenant budget. This is for you if you sell an API by plan and issue more than one key per customer.

Short answer

Key the limiter on the principal you resolved during authentication, not on the socket address. Check the tenant window and the key window together, refuse on the first one that is spent, and name the policy in the response. Spend nothing until both checks pass, so a request the key policy already refused does not consume the tenant’s allowance as well.

You will need

Node 22 or later, and an API whose authentication resolves a key and the tenant that owns it. The sample keeps its counters in a map so the logic is visible. A real deployment puts them in Redis or in the gateway, which changes where the state lives and nothing about the policy.

Approaches compared

ApproachWhen it fitsWhat it costs youWhen to pick something else
express-rate-limit with a custom keyAn Express service where you want one limit keyed on anything you knowOne policy per middleware instance, so two limits means two passes and two storesYou need a refusal to leave both budgets untouched
Kong rate-limiting by consumerThe gateway already authenticates and every service sits behind itThe gateway only knows the identities it resolved, so a tenant it cannot see cannot be a keyYour tenant comes from a database lookup the gateway does not do
rate-limiter-flexible RateLimiterUnionSeveral limits that have to pass together, with Redis or PostgreSQL behind themA library and a store to run, and a union that answers with the longest wait rather than a nameA single limit covers the service and the extra machinery earns nothing
slowapi for FastAPIA FastAPI service that wants the same keying idea in PythonA decorator per route, so a missed route is unlimited and nothing says soYou want the limit applied once, centrally, for every route

Two of these live in your process and two live at the edge. In-process limiting can key on anything authentication produced, including a tenant that came from a database row. Gateway limiting sees only the identity the gateway itself resolved, which is usually the key and not the account behind it. The edge wins on blast radius, because it sheds load before your service allocates anything. Running both is normal: a coarse limit at the gateway to protect the fleet, and a precise one in the service to enforce what a customer bought.

Key on the principal, never on the socket

Resolve the caller first, then limit. The client address is an attribute of the network path, and the network path is not the customer.

export function principalOf(req) {
  const header = req.headers?.authorization ?? ''
  const token = header.replace(/^Bearer\s+/i, '')
  return KEYS[token] ?? null
}

An unauthenticated request has no principal, so it needs a separate and much smaller limit of its own, keyed on whatever you do have. That is the one place an address belongs. Read it from a proxy header you trust rather than from the socket, because every request behind a load balancer shares one.1

Check both windows before spending either

The order matters, and so does the moment of the increment.

return function check(principal) {
  const tenant = peek(`tenant:${principal.tenant}`, perTenant)
  if (!tenant.ok) return { allowed: false, policy: 'tenant', ...tenant }
  const key = peek(`key:${principal.key}`, perKey)
  if (!key.ok) return { allowed: false, policy: 'key', ...key }
  spend(tenant)
  spend(key)
  return { allowed: true, policy: 'key', ...key, used: key.used + 1 }
}

peek reads a window and spend writes it. Splitting them is the difference between a correct limiter and one that punishes a customer twice. A request refused because one key is noisy would otherwise still consume a slot from the account budget. A client retrying that refusal would then drain a tenant window its requests never reached.

The refusal names the policy. Return that name in the body and in a RateLimit header.2 Slow down and your plan is spent call for different actions on the client side. A client that cannot tell them apart retries the second one forever, and every retry is another refusal to log, another alert, and another support ticket you will answer by hand.

Check it worked

Run the demo. Three keys, two tenants, a key budget of three, and a tenant budget of five.

node demo.mjs
-- acme key 1 spends its own allowance
sk_live_acme_1   acme    200
sk_live_acme_1   acme    200
sk_live_acme_1   acme    200
sk_live_acme_1   acme    429 key limit
-- acme key 2 is unaffected until the tenant limit binds
sk_live_acme_2   acme    200
sk_live_acme_2   acme    200
sk_live_acme_2   acme    429 tenant limit
-- globex is untouched by any of it
sk_live_globex   globex  200

Read the fourth and seventh lines together. The first key is refused by its own policy and the second key keeps working, which is the per-key budget doing its job.3 The tenant budget then binds across both keys, and the other tenant is unaffected throughout.

node --test limits.test.mjs
1..5
# tests 5
# suites 0
# pass 5
# fail 0
# cancelled 0
# skipped 0
# todo 0
# duration_ms 114.473213

When it goes wrong

Every caller shares one bucket. The limiter fell back to the socket address and the service sits behind a proxy, so every request arrives from the same place. Resolve the principal, and give anonymous traffic its own small allowance.

A customer reports refusals that your counters do not show. The limit is per process and the service runs several. Move the counters to a shared store, or divide the published limit by the process count and say so.

Refusals arrive in bursts on the minute. A fixed window resets all at once, so clients that backed off retry together. Use a sliding window, or add jitter on the client side.

The counters are right, but the bill is still wrong. A limit is not a quota. A rate limit shapes traffic inside a window and forgets it; a quota is a monthly total that has to survive restarts and deployments. Keep them apart, and store the quota where your billing data already lives.

When not to do this

Do not put the only limit in your process. An in-process check runs after the connection, the TLS handshake, and the authentication lookup, so a flood still costs you all three. Keep a coarse limit at the edge and treat the fine-grained one as policy rather than as protection.

Do not enforce a tenant limit a customer cannot see. A shared budget that binds without warning reads as an outage on their side, and the first they learn of it is a support ticket. Publish the number, report remaining allowance on every response, and refuse with the policy named.

Do not key on the user inside the tenant when the plan is sold to the tenant. It feels fairer, and it moves the limit away from the thing you priced. A customer who adds users then gains capacity nobody sold them, and the invoice never mentions it.

Last verified

Verified 2026-09-14 against Node 22.22.2. Both output blocks are what the preceding command printed.

Footnotes

  1. The header is in no specification, and the specification written to replace it has had little effect. MDN files X-Forwarded-For as a standard in practice only, and points to Forwarded, the standardized version, which it describes as much less frequently used. RFC 7239 defined Forwarded in June 2014, and its introduction names X-Forwarded-For among the fields it was written to supersede. Twelve years on, the standard is the one that needs introducing. ↩︎ Back to text

  2. The draft has been a draft for a while. The first revision, draft-polli-ratelimit-headers-00, is dated September 2019, and revision 11 under the working group’s name is dated May 2026 and expires in November. A section marked for removal before publication surveys what servers send in the meantime. X-RateLimit-Remaining means seconds, milliseconds, a UNIX timestamp, or a date, depending on the implementation. The draft exists because of that section, and the section will be deleted the day the draft succeeds. ↩︎ Back to text

  3. The status code is younger than the practice. RFC 6585 added 429 Too Many Requests in April 2012, alongside 428, 431, and 511. Its example response is an HTML page allowing 50 requests per hour per logged-in user, and the example says so in the first person. The same section says the specification does not define how the origin server identifies the user, nor how it counts requests. Keying on the API key and the tenant is therefore neither required nor forbidden. It is a choice the standard declined to make, which is how the socket address got the job. ↩︎ Back to text

Read this page as markdown · All how-to guides

Generate the client instead of writing it#

Retries, timeouts, pagination and auth are the same problems in every client. Voxgig generates them from your OpenAPI description, in 23 languages, from one model.

Get the Voxgig dispatch

Short notes on building SDKs, CLIs, REPLs, and MCPs for API-first teams, plus the occasional Fireside episode pick.

By signing up you agree to our Terms and Conditions.