SaaS technology
Businesses SaaS August 5, 2026 • 11 min read

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:

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

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:

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