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
| Approach | When it fits | What it costs you | When to pick something else |
|---|---|---|---|
| express-rate-limit with a custom key | An Express service where you want one limit keyed on anything you know | One policy per middleware instance, so two limits means two passes and two stores | You need a refusal to leave both budgets untouched |
| Kong rate-limiting by consumer | The gateway already authenticates and every service sits behind it | The gateway only knows the identities it resolved, so a tenant it cannot see cannot be a key | Your tenant comes from a database lookup the gateway does not do |
| rate-limiter-flexible RateLimiterUnion | Several limits that have to pass together, with Redis or PostgreSQL behind them | A library and a store to run, and a union that answers with the longest wait rather than a name | A single limit covers the service and the extra machinery earns nothing |
| slowapi for FastAPI | A FastAPI service that wants the same keying idea in Python | A decorator per route, so a missed route is unlimited and nothing says so | You 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.
Related how-tos
Last verified
Verified 2026-09-14 against Node 22.22.2. Both output blocks are what the preceding command printed.
Footnotes
-
The header is in no specification, and the specification written to replace it has had little effect. MDN files
X-Forwarded-Foras a standard in practice only, and points toForwarded, the standardized version, which it describes as much less frequently used. RFC 7239 definedForwardedin June 2014, and its introduction namesX-Forwarded-Foramong the fields it was written to supersede. Twelve years on, the standard is the one that needs introducing. ↩︎ Back to text -
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-Remainingmeans 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 -
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