One Cache Key, Two Customers, and a Very Bad Morning

Share
One Cache Key, Two Customers, and a Very Bad Morning. Abstract bug hunt illustration in orange and dark grey on debugly.dev

The report came from a customer who screenshotted a competitor's wholesale price on what they believed was their own account. It was not a permissions bug in the access control sense. The data layer had correctly enforced who could ask. The cache had already answered before anyone asked, and the cache did not know there were two someones.

The key was the product slug. Both tenants sold a product with the same slug. Whoever's request populated the cache first defined the answer for both. This is the multi tenant version of the auth state cache bug in logged in users saw somebody else's search results, and it is worth its own autopsy because the tenant dimension is the one most often omitted.

This was a Node 22.14 multi tenant service with Redis 7.2, and the defect lived in one line of key construction.

The symptom, precisely

A tenant scoped read returned data belonging to a different tenant, but only when the requested resource happened to collide on the natural key, and only when the other tenant's request had populated the cache recently. Anonymous or single tenant deployments could never reproduce it, which is why it survived staging. The collision required two tenants and one shared identifier and a warm cache, a three way coincidence that testing never assembled.

The hypotheses that died

Access control is broken

First I checked authorisation. The request path validated the tenant against the resource correctly when it went to the database. A direct database read returned the right tenant's row.

It died because the authority layer was fine. The wrong answer was served before authorisation had a chance to disagree.

The database has cross tenant rows

Next I suspected bad data, a row with the wrong tenant id. A query scoped by tenant returned only that tenant's rows, clean.

It died because the data was correct. The cross tenant bleed was not in storage.

The session is leaking

Then I checked the session, as in the earlier search bug. The tenant id on the request was correct at the handler.

It died because identity was right at entry. The loss happened at the cache read, where the tenant was simply not part of the lookup.

The breakthrough

The cache key was:

const key = `price:${slug}`;

The tenant was applied to everything except the one line that mattered. The fix is one dimension:

const key = `price:${tenantId}:${slug}`;

But as before, the one line fix is not the fix, it is the patch. The defect is that key construction was ad hoc, and every ad hoc key is a guess about which dimensions vary. The slug varied. So did the tenant. The key included one of them.

Why the tenant dimension gets omitted

The omission is rarely careless. It is usually an optimisation. Someone noticed two tenants request the same public resource and reasoned that the answer is identical, so sharing the cache entry is a win. That reasoning is correct for genuinely public data and catastrophic for data that is scoped, because the scoping is invisible in the resource's natural key.

The dangerous category is data that is sometimes scoped. Pricing is public for the retail tenant and negotiated for the wholesale tenant, so the same slug has tenant dependent answers. Any resource whose answer depends on who asks must carry the asker in the key, and the moment a resource moves from public to scoped, every existing key becomes wrong.

What I changed

One key builder, tenant first. All cache keys go through a helper that takes the tenant as a required first argument and throws if a scoped read attempts a key without one. The builder makes the dimension explicit at every call site.

Namespace by scope class. Keys for public data and keys for scoped data live under different prefixes, so a public entry can never be read as a scoped one and vice versa. The prefix is a type for the key's trust boundary.

The isolation test, again. Two tenants, same slug, different expected values, seed one, read the other, assert no bleed. This test is now a template applied to every new cached endpoint, because the failure mode is identical every time and only the key differs.

An audit of existing keys. I grepped every key construction and classified each as public or scoped, and added the tenant to every scoped one. The audit found three more endpoints with the same omission, none of which had produced a report yet.

What I would do differently

I would have treated the cache key as a security boundary, because in a multi tenant system it is one. The key decides whose data is returned, which is the definition of an authorisation decision, and it was being made by string concatenation with no review.

I would also have logged key collisions across tenants during development, a counter for the same key being read under different tenant contexts. That counter is the bleed detector, and it would have fired on day one instead of on a customer screenshot.

The rule

A cache key must contain every dimension on which the answer varies, and in a multi tenant system the tenant is a dimension on almost everything. Build keys through one helper that forces the scope, and treat any scoped read whose key lacks its scope as an authorisation bug, because that is precisely what it is.

The single tenant version of this defect is the search results bug linked above, and the performance version, where the omitted dimension is time rather than identity, is cache invalidation and stale data. The three are one lesson: a key that omits a varying dimension is a lie about which request is which, and the only difference between them is which dimension was left out and who got hurt.

Read more