Preventing Cross-Tenant Leaks in Shared Caches

Row-level security protects the database, and then a cache in front of it serves one tenant’s data to another because a key was missing a prefix. This page is part of the multi-tenant authorization and data isolation guide and covers every layer where a shared cache can cross the boundary the database was carefully defending.

The Failure Is Always a Key

Cache leaks have one cause: a key that does not include everything the value depends on. If two tenants can produce the same key for different data, one will eventually read the other’s.

A key that omits the tenant versus one that leads with it A key built only from the resource identifier collides across tenants, while a key that leads with the tenant identifier is scoped by construction and can be scanned or invalidated per tenant. Omits the tenant invoice:4711 two tenants, one key — a leak Tenant first t:acme:invoice:4711 scoped, and prunable per tenant
Leading with the tenant is not only about collisions — it also makes "delete everything for this customer" a prefix operation rather than a scan.

Include everything the value varies by: the tenant, the subject where the value is per-user, the locale if the value is localised, and a version you can bump. A key that captures only the resource identifier is a bet that nothing else affects the value, and that bet loses the first time a feature adds a dimension.

One Key Builder, Used Everywhere

The rule is easy to state and hard to keep, because keys are constructed by string interpolation all over a codebase. Replace that with a single function that cannot be called without the context.

// The only way to build a cache key — the context is required, not optional.
export function cacheKey(ctx: RequestContext, parts: string[]): string {
  if (!ctx.tenantId) throw new Error("cache key requires a tenant");
  return ["t", ctx.tenantId, ...parts].join(":");
}

// Usage reads naturally and cannot omit the tenant.
const key = cacheKey(ctx, ["invoice", invoiceId]);

Making the tenant a required argument turns the mistake into a compile-time or start-up error rather than a silent leak. Add a lint rule that forbids calling the cache client directly, so the builder cannot be bypassed by someone in a hurry.

Do the same for anything else keyed by content: search index document identifiers, deduplication keys, idempotency keys, rate-limit counters. Each is a namespace, and each can collide across tenants in the same way.

Layers Beyond Your Own Cache

Application caches are the obvious layer and rarely the only one.

Caching layers that can cross a tenant boundary The application cache, the framework's own data cache, a CDN or reverse proxy, and the browser's cache can each store a response that another tenant may read. Application cache your keys your rules Framework cache data and route caching by default CDN / proxy caches by URL ignores your session Browser cache shared machines and the back button
The CDN is the most dangerous because it keys on the URL alone: two tenants requesting the same path get the same cached bytes unless you tell it otherwise.

Mark every authenticated response Cache-Control: private, no-store so no shared cache retains it, and configure the CDN to bypass caching entirely for any request carrying a session cookie. Where a framework caches data or rendered routes by default, opt out explicitly on anything user- or tenant-specific — the defaults are tuned for public content and are wrong for an authenticated application.

Do not rely on a Vary header to keep tenants apart. It works in principle and is fragile in practice: intermediaries handle it inconsistently, and a single misconfigured proxy turns a correct Vary into a cross-tenant leak.

Invalidation Is Part of Isolation

A cache that never returns another tenant’s data can still return one tenant’s stale data, and where that data drives an authorization decision, staleness is a security problem rather than a freshness one.

Key cached authorization decisions by a subject version, as described in caching authorization decisions at the API gateway, so a permission change invalidates them implicitly. Keep a per-tenant version too, bumped when tenant-wide settings change, so a configuration change does not require enumerating keys.

Make offboarding a prefix delete. When a customer leaves, everything under their prefix goes in one operation, across every cache — which only works if every layer used the same prefix convention from the beginning.

Cache Warmers and Background Jobs

The subtlest leaks come from code that populates a cache outside a request, because there is no session to take the tenant from and the developer has to supply it by hand.

A warmer that iterates over resources and caches each one must carry the tenant through the loop rather than deriving it once at the top, or a single mis-scoped iteration writes one tenant’s data under another’s key. Pass a tenant-scoped context object into every iteration and make the key builder demand it, exactly as a request handler would.

Scheduled reports and exports have the same shape with higher stakes, because their output is often a file that lands somewhere durable. Set the tenant explicitly at the start of the job, apply it to both the database session and the cache context, and assert it in the job’s own tests with a two-tenant fixture.

Where background work loses the tenant A job derives the tenant once and reuses it across an iteration covering several tenants, so cache entries and outputs are written under the wrong scope. Tenant set once loop covers several tenants entries written under the wrong scope Tenant per iteration context rebuilt for each item scope always matches the data
Batch jobs process many tenants in one process, which is exactly the condition a per-request assumption fails under.

Proving It Holds

Two tests catch essentially every occurrence.

The first is a two-tenant integration test that requests the same logical resource as each tenant, in both orders, and asserts each receives its own data. Run it with the cache warm as well as cold, since a leak by definition only appears on the second request.

The second is a key-audit test: instrument the cache client in a test environment to record every key written, run your integration suite, and assert that every key begins with a tenant prefix. That converts a convention into a check, and it catches the direct client call somebody added while debugging.

Add a scheduled production audit if the stakes justify it: sample keys, verify they parse into the expected shape, and alert on any that do not. Caches are edited by many people over time, and a key format that was universal at launch rarely stays that way without something watching.

What to Do After a Suspected Leak

If a cache leak is suspected, the response order matters, because the evidence disappears quickly.

Capture first: snapshot the affected keys and their values before flushing anything, since a flush destroys the only record of what was stored under which key. Then flush the affected namespace — a tenant prefix if you can scope it, the whole cache if you cannot, accepting the load that follows over leaving suspect entries in place.

Then determine exposure from the keys rather than from the code. The question is which keys lacked a tenant scope, when they were written, and what was read from them; access logs joined against those keys tell you which tenants could have received another’s data, which is the fact any notification obligation turns on.

Fix the key builder before restoring traffic, and add the audit test that would have caught it as part of the same change. A cache leak fixed without that test is a cache leak scheduled to recur, because the pressure that produced the direct client call will return.

Frequently Asked Questions

Is a shared cache instance across tenants acceptable at all?

Yes, provided keys are scoped and the discipline is enforced by a shared builder. Separate instances per tenant are cleaner in theory and impractical past a handful of customers. The security property comes from the key namespace, not from the physical separation — which is why the key builder is worth protecting with a lint rule.

What about caching at the CDN for logged-in users?

Bypass it. Any response that depends on a session must not be stored by a shared cache, so mark it private, no-store and configure the CDN to skip caching when a session cookie is present. If you need edge caching for authenticated content, do it with a per-user key at a layer that understands identity rather than with a URL-keyed cache that does not.

Does the tenant need to be first in the key?

It needs to be present; leading with it is a practical convenience. A tenant-first key makes prefix scans, per-tenant invalidation and offboarding straightforward, and it makes a malformed key obvious at a glance. Putting it in the middle works for correctness and makes every operational task harder.

How do I handle genuinely global cached data?

Give it an explicit namespace of its own — a global: prefix rather than an absent tenant — so a missing tenant is always an error rather than an ambiguity. The dangerous shape is a cache where some keys legitimately omit the tenant, because then the audit test cannot distinguish intentional global data from a bug.

What about the browser's own cache?

It matters on shared machines and for the back button. private allows browser caching, which is usually fine, but anything sensitive should use no-store so it is not written to disk and cannot be redisplayed after logout. Test it by signing out and pressing back — if the previous authenticated page renders, the response was cacheable when it should not have been.