API Key Scoping & Rate Limits
Deciding what a rate limit counts is as important as deciding how big it is: the same caller can be metered per account, per API key, per granted scope, or per route, and each choice produces a different fairness model and a different Redis memory footprint. This guide covers how to scope limits and structure hierarchical keys without exploding cardinality, and it extends Tiered Access & Quota Enforcement from “which tier” to “which bucket within a tier”. The limiter itself is still a token bucket in Redis; scoping only changes the key you hand it.
The problem in concrete numbers
An account holds 4 API keys. One key is a high-volume server integration that should get the full 100 rps; another is an embedded mobile key that should be capped at 5 rps so a buggy app release can’t drain the account’s budget. Meanwhile a single expensive route — POST /v1/exports, which spawns a background job — must be held to 2 req/min regardless of the caller’s general limit. Counting everything in one bucket per account can’t express any of this. But naively keying by (account, key, route, scope) for an account that hits 300 distinct routes creates 4 × 300 = 1,200 Redis keys for one account, and at scale that cardinality is what runs your Redis out of memory.
Comparison: scoping strategies
| Strategy | Key shape | Fairness model | Cardinality | Use when |
|---|---|---|---|---|
| Per account | acct:{id} |
One budget for the whole customer | 1 / account | Billing-aligned; default |
| Per API key | key:{id} |
Each key independent | N keys / account | Keys map to apps/environments with separate budgets |
| Per scope/permission | acct:{id}:scope:{s} |
Limit by granted capability | few / account | Read vs write, or privileged scopes need tighter caps |
| Per route | acct:{id}:route:{r} |
Protect one expensive endpoint | routes used / account | A few costly endpoints; not all routes |
| Per endpoint class | acct:{id}:class:{c} |
Group routes into buckets | small fixed set | Many routes, want bounded keys |
| Hierarchical | acct → key → route |
Nested caps, all enforced | bounded by classing | Account ceiling + per-key + per-expensive-route |
Rule of thumb: scope by the axis you actually need to differentiate, and collapse everything else into a class. Don’t open a key dimension “just in case” — every dimension multiplies cardinality.
Hierarchical keys: account → key → endpoint
The production pattern enforces several caps in a hierarchy, cheapest first, and rejects on the first that fails:
- Account ceiling — the tier’s overall limit (the customer’s total budget).
- Per-key cap — an optional tighter limit on this specific key (the mobile key at 5 rps).
- Per-route / per-class cap — a tight limit on a named expensive endpoint only.
A request must pass every applicable level. Checking the broad account ceiling first means a flood is rejected before you evaluate the narrower (and rarer) route rule.
// Hierarchical scoping: evaluate account -> key -> route, reject on first fail.
import Redis from "ioredis";
const redis = new Redis(process.env.REDIS_URL!);
// One reusable atomic token-bucket check (returns [allowed, remaining]).
const BUCKET = `
local b = redis.call('HMGET', KEYS[1], 'tokens', 'ts')
local cap, rate, now = tonumber(ARGV[1]), tonumber(ARGV[2]), tonumber(ARGV[3])
local tokens = math.min(cap, (tonumber(b[1]) or cap) + (now - (tonumber(b[2]) or now))/1000*rate)
local ok = 0
if tokens >= 1 then tokens = tokens - 1; ok = 1 end
redis.call('HSET', KEYS[1], 'tokens', tokens, 'ts', now)
redis.call('PEXPIRE', KEYS[1], math.ceil(cap/rate*1000) + 1000)
return { ok, math.floor(tokens) }`;
type Level = { key: string; cap: number; rate: number };
// Build only the levels that apply: account always, key/route only if configured.
function levelsFor(ctx: {
account: string; apiKey: string; routeClass: string;
accountCap: number; accountRate: number;
keyCap?: number; keyRate?: number;
routeCap?: number; routeRate?: number;
}): Level[] {
const ls: Level[] = [
{ key: `rl:acct:${ctx.account}`, cap: ctx.accountCap, rate: ctx.accountRate },
];
if (ctx.keyCap) ls.push({ key: `rl:key:${ctx.apiKey}`, cap: ctx.keyCap, rate: ctx.keyRate! });
if (ctx.routeCap) ls.push({ key: `rl:route:${ctx.account}:${ctx.routeClass}`, cap: ctx.routeCap, rate: ctx.routeRate! });
return ls;
}
export async function allow(ctx: Parameters<typeof levelsFor>[0]) {
const now = Date.now();
for (const lv of levelsFor(ctx)) { // broad -> narrow
const [ok, remaining] = (await redis.eval(BUCKET, 1, lv.key, lv.cap, lv.rate, now)) as [number, number];
if (ok !== 1) return { allowed: false, scope: lv.key, remaining };
}
return { allowed: true, scope: "all", remaining: -1 };
}
Controlling cardinality with key-class buckets
Per-route limiting is valuable for a handful of expensive endpoints but ruinous if applied to all of them. Collapse routes into a small fixed set of classes so the key dimension is bounded no matter how many routes you ship:
// Map any route to a small, fixed class set -> bounded Redis cardinality.
const ROUTE_CLASS: Array<[RegExp, string]> = [
[/^\/v1\/exports/, "heavy"], // job-spawning, expensive
[/^\/v1\/search/, "search"], // moderately expensive
[/^\/v1\/(get|list)/, "read"], // cheap reads
];
function classify(path: string): string {
return ROUTE_CLASS.find(([re]) => re.test(path))?.[1] ?? "default";
}
// 300 routes -> at most 4 classes -> 4 route keys per account, not 300.
With classing, an account with 300 routes holds at most a few route-class keys instead of hundreds, and you still get a tight cap on the heavy class.
Gotchas & edge cases
- Hierarchy order matters. Check the broad account ceiling before the narrow route cap so floods are shed cheaply and you don’t waste a Redis round-trip evaluating a rare route rule on traffic that’s already over budget.
- Unbounded route keys are a memory leak. Per-raw-route keys grow with every endpoint and every path parameter (
/v1/users/{id}becomes millions of keys). Always classify, never key by the raw path. - Scope confusion on shared keys. If a key has both
readandwritescopes, decide whether the limit is per-scope or shared; a per-scope bucket lets a read flood and a write flood run independently, which may or may not be what you want. - Per-key caps can exceed the account ceiling. A per-key cap only ever tightens; the effective limit is the minimum of all applicable levels. Never let a key cap be advertised as larger than the account budget.
- Each level is another round-trip. Three levels = three Redis calls unless you batch them into one Lua script with multiple
KEYS. Batch when latency matters.
Verification & testing
# Per-key clamp: mobile key limited to 5 rps even though the account allows 100.
hey -z 3s -c 10 -H "X-API-Key: mobile_demo" https://api.example.com/v1/ping \
| grep -E "Requests/sec|Status code distribution" -A4 # expect ~5 rps accepted
# Route-class clamp: heavy class at 2/min. Fire 5 quick exports -> 2 pass, 3 -> 429.
for i in $(seq 1 5); do
curl -s -o /dev/null -w "%{http_code} " -H "X-API-Key: server_demo" \
-X POST https://api.example.com/v1/exports
done; echo
Confirm Redis key cardinality stays bounded with redis-cli --scan --pattern 'rl:route:*' | wc -l after a load test; if it grows with traffic, a raw route is leaking into the key.
Frequently Asked Questions
Should I scope limits per account or per API key?
Default to per account because billing and fairness are account-level. Scope per API key when keys represent separate environments or apps that each deserve an independent budget — for example a production key and a staging key under one account.
How do I limit one expensive endpoint without limiting everything?
Add a per-route-class cap on top of the account ceiling. Classify the route (e.g. heavy) and key the bucket as rl:route:{account}:heavy with a tight limit. Cheap routes fall into a default class and are governed only by the account ceiling.
Won't per-route limiting blow up Redis memory?
It will if you key by the raw path, because path parameters and hundreds of endpoints multiply keys. Collapse routes into a small fixed set of classes so an account holds a handful of route keys regardless of how many distinct routes it calls.
What's the effective limit when several levels apply?
The minimum. Each level can only tighten, so a request is allowed only if every applicable bucket (account, key, route) has capacity. A per-key cap of 5 rps under a 100 rps account ceiling yields an effective 5 rps for that key.
Related
- Tiered Access & Quota Enforcement — the parent topic on per-plan limits and quotas.
- Per-Tier Quota Enforcement With Redis — the atomic Lua build this scoping plugs into.
- Billing-Critical Sliding-Log Usage — exact metering when scoped usage drives invoices.
- Redis Counter Architecture — key schemas and cardinality control.
- Token Bucket Implementation — the per-bucket algorithm.