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:

  1. Account ceiling — the tier’s overall limit (the customer’s total budget).
  2. Per-key cap — an optional tighter limit on this specific key (the mobile key at 5 rps).
  3. 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.

Hierarchical scoping from account ceiling to per-key to per-route-class A request passes the account ceiling, then a per-key cap, then a per-route-class cap, rejected on the first level that fails. Account ceiling — tier total budget rl:acct:{id} — checked first Per-key cap (optional) — rl:key:{id} e.g. mobile key clamped to 5 rps Per-route-class cap — rl:route:{acct}:{class} e.g. heavy class clamped to 2/min reject on first fail

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 read and write scopes, 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.