Rate-Limit a Multi-Tenant API Without Leaking Tenant State
For: A backend engineer at a 20-40 person B2B SaaS company whose API is shared across all tenants on a single Redis instance — and who just had an enterprise prospect ask how they prevent one tenant's traffic spike from affecting others, and whether tenant usage data is ever co-mingled
If your Redis rate limiter uses keys like ratelimit:{tenant_id}:{route}, you have two problems your enterprise prospect already knows about: one tenant's spike can saturate a shared counter, and an attacker can probe TTL or EXISTS against guessed tenant IDs to confirm which tenants are live on your platform. The fix is a keying scheme that (1) hashes tenant identity with a server-side secret before it ever touches Redis, and (2) gives every tenant its own sliding window budget with a separate global safety valve. Everything below is runnable against a local Redis 7+ instance.
Why the canonical pattern leaks
Almost every rate-limiting tutorial you'll find keys on something like rl:{userId}:{route}. This works until someone with a security checklist asks two questions:
- Isolation: If tenant A and tenant B are both on the "Pro" plan with a shared 10k req/min ceiling, can A's runaway job starve B? With a shared counter, yes.
- Confidentiality: Can an outsider learn which tenant IDs exist? If tenant IDs are guessable (sequential, or a known customer's slug), an attacker who can reach any code path that touches the counter — even an unauthenticated 401 handler that still increments — can use
TTLresponse timing,SCANleaks from ops tooling, or the presence of a Redis key in a shared cluster to confirm the ID exists.
Encryption at rest doesn't help. The leak is in the namespace, not the value. The fix is to make the Redis key a keyed hash of the tenant identity, computed with a secret that never leaves your API process.
Prerequisites
- Redis 7.0 or later (we'll use server-side Lua for atomicity)
- Python 3.10+ with
redis-py5.x (pip install redis) - A 32-byte random secret in an env var — generate with
python -c "import secrets; print(secrets.token_hex(32))" - Basic familiarity with sliding window counters
Step 1: Generate and store the keying secret
This secret is the pepper for your tenant IDs. It must not be checked into source and must be identical across all API nodes hitting the same Redis. Rotating it invalidates all counters, which is fine — worst case a few tenants get a fresh budget window.
export RATELIMIT_PEPPER=$(python -c "import secrets; print(secrets.token_hex(32))")
echo $RATELIMIT_PEPPER
# 7f3c... (64 hex chars)
Expected: a 64-character hex string. Store it in your secrets manager (AWS Secrets Manager, Vault, Doppler). Not in .env committed to git.
Step 2: Derive the opaque Redis key
Instead of putting tenant_id directly in the key, we compute HMAC-SHA256(pepper, tenant_id + route) and truncate to 128 bits. That's enough entropy to make offline guessing worthless — an attacker who dumps your Redis sees opaque hex, and an attacker who can only probe from outside can't construct a valid key without the pepper.
import hmac, hashlib, os
PEPPER = bytes.fromhex(os.environ["RATELIMIT_PEPPER"])
def bucket_key(tenant_id: str, route: str) -> str:
msg = f"{tenant_id}|{route}".encode()
digest = hmac.new(PEPPER, msg, hashlib.sha256).digest()
return "rl:" + digest[:16].hex() # 32 hex chars
print(bucket_key("acme-corp", "POST /v1/invoices"))
# rl:9b2f4a1c8d7e6f503a1b2c3d4e5f6071
Expected: a deterministic but opaque key. Same tenant + route always maps to the same bucket. Different tenants on the same route produce keys with no visible relationship.
Step 3: Write the sliding window Lua script
Fixed windows have edge bursts (a client can send 2x the limit across a window boundary). Token buckets are fine but require careful clock handling. A sliding window log using a sorted set is exact and atomic in one round trip.
-- sliding_window.lua
-- KEYS[1] = bucket key
-- ARGV[1] = now (ms), ARGV[2] = window (ms), ARGV[3] = limit, ARGV[4] = request id
local key = KEYS[1]
local now = tonumber(ARGV[1])
local window = tonumber(ARGV[2])
local limit = tonumber(ARGV[3])
local req_id = ARGV[4]
-- drop expired entries
redis.call('ZREMRANGEBYSCORE', key, 0, now - window)
local count = redis.call('ZCARD', key)
if count >= limit then
local oldest = redis.call('ZRANGE', key, 0, 0, 'WITHSCORES')
local retry_after = window - (now - tonumber(oldest[2]))
return {0, count, retry_after}
end
redis.call('ZADD', key, now, req_id)
redis.call('PEXPIRE', key, window)
return {1, count + 1, 0}
This runs entirely inside Redis, so there's no TOCTOU between the check and the increment. The PEXPIRE on every write keeps idle tenants from accumulating cold keys forever.
Step 4: Wire it up in Python
import time, uuid
import redis
r = redis.Redis(host="localhost", port=6379, decode_responses=True)
with open("sliding_window.lua") as f:
LIMITER = r.register_script(f.read())
def check_limit(tenant_id: str, route: str, limit: int, window_ms: int):
key = bucket_key(tenant_id, route)
now = int(time.time() * 1000)
req_id = f"{now}-{uuid.uuid4().hex[:8]}"
allowed, count, retry_after = LIMITER(
keys=[key], args=[now, window_ms, limit, req_id]
)
return bool(allowed), count, retry_after
# Simulate: acme-corp allowed 5 req/second
for i in range(7):
ok, count, retry = check_limit("acme-corp", "POST /v1/invoices", 5, 1000)
print(f"req {i+1}: allowed={ok} count={count} retry_after_ms={retry}")
Expected output:
req 1: allowed=True count=1 retry_after_ms=0
req 2: allowed=True count=2 retry_after_ms=0
req 3: allowed=True count=3 retry_after_ms=0
req 4: allowed=True count=4 retry_after_ms=0
req 5: allowed=True count=5 retry_after_ms=0
req 6: allowed=False count=5 retry_after_ms=987
req 7: allowed=False count=5 retry_after_ms=982
Step 5: Add per-tenant isolation with a global safety valve
The reason one tenant can starve another on the naive design is a single shared counter for the plan tier. Fix it with two checks: the per-tenant budget (what they paid for), and a global-per-route ceiling that protects your infrastructure. Reject if either fails.
def check_limit_isolated(tenant_id, route, tenant_limit, tenant_window,
global_limit, global_window):
# per-tenant check
ok_t, count_t, retry_t = check_limit(tenant_id, route, tenant_limit, tenant_window)
if not ok_t:
return False, "tenant_quota_exceeded", retry_t
# global check — key is HMAC of route only, no tenant
global_key = "rl:global:" + hmac.new(
PEPPER, route.encode(), hashlib.sha256
).digest()[:16].hex()
now = int(time.time() * 1000)
req_id = f"{now}-{uuid.uuid4().hex[:8]}"
ok_g, count_g, retry_g = LIMITER(
keys=[global_key], args=[now, global_window, global_limit, req_id]
)
if not ok_g:
# roll back the tenant increment so they don't get penalized
# for a global rejection (optional; skip if you'd rather be strict)
return False, "global_capacity", retry_g
return True, "ok", 0
Now tenant A hitting their 1000 req/min ceiling doesn't touch tenant B's 1000 req/min ceiling. The global counter is a circuit breaker, not a shared quota.
Step 6: Verify the leak is closed
The whole point of this exercise. Run these two probes:
# Attempt 1: does the naive key exist? (it shouldn't — we never wrote one)
redis-cli EXISTS "rl:acme-corp:POST /v1/invoices"
# (integer) 0
# Attempt 2: can we find the real key without the pepper?
redis-cli --scan --pattern "rl:*" | head
# rl:9b2f4a1c8d7e6f503a1b2c3d4e5f6071
# rl:global:2e5d...
# rl:a1b2c3d4e5f60718293a4b5c6d7e8f90
An operator with SCAN access sees only opaque hashes. They cannot reverse a hash to acme-corp without the pepper. An external attacker who can only trigger your API can't probe for acme-corp's existence because they can't construct the Redis key.
Step 7: Emit rate-limit headers without leaking
Return standard headers so clients back off cleanly. Do not include the bucket key or any hashed value in headers — they're not sensitive, but there's no reason to publish them.
def apply_headers(response, allowed, count, limit, retry_after_ms):
response.headers["RateLimit-Limit"] = str(limit)
response.headers["RateLimit-Remaining"] = str(max(0, limit - count))
if not allowed:
response.headers["Retry-After"] = str(max(1, retry_after_ms // 1000))
return response
Step 8: Load-test the isolation claim
Before you tell the enterprise prospect "one tenant can't affect another," prove it. Two terminals, two tenants, one saturating, one polite:
# Terminal 1: tenant "noisy" hammers at 500 rps
hey -z 30s -c 50 -H "X-Tenant: noisy" http://localhost:8000/v1/invoices
# Terminal 2: tenant "polite" sends 10 rps
hey -z 30s -c 2 -q 5 -H "X-Tenant: polite" http://localhost:8000/v1/invoices
Expected: noisy sees a high 429 rate as it blows through its per-tenant budget. polite sees 0% or near-0% 429s. If polite also gets throttled, your global ceiling is set too low relative to the number of active tenants — raise it or move to a tenant-weighted global check.
Common errors
NOSCRIPT No matching script
Redis flushed the script cache (usually after a restart or failover). register_script in redis-py handles reload automatically on newer versions; if you're calling EVALSHA directly, catch this and fall back to EVAL.
Counters drift after clock skew
The Lua script uses the timestamp you pass in from the app. If your API nodes have skewed clocks, use redis.call('TIME') inside the script instead of ARGV[1]. Costs one extra Redis call worth of internal work but eliminates skew.
Memory growth on high-cardinality routes
Sliding window logs store one sorted-set entry per request. At 10k rps sustained, that's a lot of RAM. Options: (1) switch to a sliding window counter approximation (two fixed windows, weighted) for high-volume routes; (2) lower the window; (3) shard by tenant tier so free-tier abusers hit a lightweight approximation and paying tenants get the exact log.
Pepper rotation resets everyone's budget
Expected. If this is disruptive, keep the previous pepper for one window duration and check both keys during rotation — allow the request if either bucket has capacity, write to the new bucket only.
Cluster mode: CROSSSLOT errors
The two-key check (tenant + global) will fail on Redis Cluster because the keys hash to different slots. Fix with hash tags: rl:{route_slot}:tenant_hash and rl:{route_slot}:global, where {...} pins both to the same slot. Trade-off: hot routes concentrate on one shard.
What this approach is bad at
Honest tradeoffs:
- Debuggability suffers. When a customer complains "why am I being throttled," you can't
redis-cli GET rl:acme-corp:...anymore. Build a support tool that takes a tenant ID, computes the hash server-side, and dumps the bucket. Don't let engineers do it ad hoc. - Sorted-set logs are memory-heavy. For very high-QPS endpoints, use the approximation.
- Global safety valve is a blunt tool. If it trips, well-behaved tenants get 429s alongside abusers. Monitor its trip rate and treat every trip as a capacity-planning signal.
- Pepper is now a critical secret. Losing it means all counters reset. Leaking it means an attacker can enumerate tenants again. Rotate on the same schedule as your other high-value secrets.
How CodeNicely can help
We've built this pattern in production for GimBooks, a YC-backed accounting SaaS where a single Redis cluster serves thousands of small-business tenants across GST filing, invoicing, and reporting endpoints — each with wildly different traffic profiles and enterprise customers who ask exactly the questions in the first paragraph of this post. The engagement covered the keying scheme, sliding window implementation, per-tier budgets, cluster-mode hash tag layout, and the internal support tooling to debug tenants without exposing raw keys. If your team is stuck between "the naive limiter works" and "the enterprise security review wants isolation guarantees," that's the gap we close. See our offerings or the broader digital transformation practice for how this fits into a larger backend hardening effort.
Frequently Asked Questions
Does per-tenant rate limiting in Redis require a separate Redis instance per tenant?
No, and you shouldn't do that below a few hundred large tenants — it's operationally expensive and doesn't improve isolation meaningfully over a properly keyed shared instance. The pattern above gives you logical isolation on shared infrastructure. Physical isolation is only necessary for regulated tenants with contractual data residency requirements.
Is HMAC of the tenant ID enough, or should I encrypt it?
HMAC is enough for this use case. You're not trying to hide the tenant ID from someone who already has it (your own application); you're trying to make the Redis namespace opaque to anyone who doesn't hold the pepper. Encryption would add a decryption step you don't need — the app never has to recover the tenant ID from the key.
Sliding window log vs. token bucket vs. GCRA — which should I pick?
Sliding window log (shown here) is exact and easy to reason about, but memory-heavy. Token bucket is memory-cheap and allows configurable burst but requires careful atomic refill logic. GCRA is elegant and O(1) memory per key but harder for on-call engineers to debug at 2am. For most B2B SaaS APIs under 5k rps per route, the sliding window log is the right default.
How do I handle rate limits across multiple regions?
Two options: regional Redis with eventual reconciliation (each region enforces its own budget, you accept some over-limit slippage), or a single global Redis with cross-region latency on every check. Most SaaS teams pick regional — the alternative adds 50-150ms to every API call. If you need exact global limits, that's a design conversation, not a tutorial answer.
How much would it cost to retrofit this into our existing API?
It depends heavily on your current architecture, Redis topology, and how coupled your rate limiter is to route-handling middleware. Contact CodeNicely for a personalized assessment — we can usually scope it after a one-hour architecture review.
Building something in SaaS?
CodeNicely partners with founders and tech teams to ship AI-native products that move metrics. Tell us about the problem you're solving.
Talk to our team_1751731246795-BygAaJJK.png)