Caching and tenancy
Published
A cache is the one place in an application where being fast and being wrong look identical from the outside. What separates one tenant's entries from another's is a key prefix. What stops a revoked permission from outliving its revocation is a lifetime. What keeps secrets off a shared server is a serializer you supplied. All three are property names on an options object read by a client you installed. We protected a file that configures one and renamed those names a group at a time.
What the sample actually does
The file builds one options object per tenant, differing only in the key prefix, and puts them through a shared cache server. Tenant A caches a user profile and a permission decision. Tenant B then asks the same cache for its own user 42, which is a different person who happens to have the same identifier - the situation every multi-tenant application is in, and the one the namespace exists for.
Two more things happen on a clock the sample controls rather than the wall clock. A permission is cached at time zero, revoked in the database at thirty seconds without anything rewriting the cache, and consulted again at ninety seconds, by which point a sixty-second lifetime should have retired it. And the profile is written through a serializer the application supplies, which drops three fields by name before anything reaches the server.
Protection alone was applied first on five presets covering both output targets, the gate profile and the compressed profile. All five behaved identically to the unprotected file, so nothing below is caused by protection on its own.
Tenant B read tenant A's user
Renaming the key prefix option is the result this article exists for. The client fell back to the library default, which is an empty prefix, so both tenants computed the same key for their own user 42. Tenant B's read went from a miss to a hit, and the values it returned were the other tenant's user: a real name and a real email address at the other tenant's domain.
There is no error, no warning and nothing anomalous in any log. The cache did exactly what a cache does: it was asked for a key, it had that key, it returned the value. The application received a well-formed user record and rendered it. This is a class of failure worth naming precisely, because it does not fit the usual categories - the data is not corrupted, not missing and not stale. It is correct data belonging to somebody else.
Nothing in a monitoring stack notices. Hit rates improve, if anything, because two tenants are now warming each other's entries. The only signal available is a customer noticing a name that is not theirs, which is the worst possible way to find out.
The reported namespace moved from the tenant prefix to (none - shared flat namespace), which is the one line that would have caught it - if anybody had been asserting it.
A revocation that never took effect
Renaming the lifetime option restored the library default, which is that entries never expire. The permission cached before the revocation was still there at ninety seconds, and the application's own line went from delete-still-permitted=false to true. The reported lifetime moved from 60s to never-expires.
A cache lifetime is usually thought of as a performance knob, which is why this arm is easy to under-rate. In an application that caches authorization decisions - and most applications that scale do - the lifetime is the upper bound on how long a revoked permission keeps working. Losing it does not merely make the cache staler; it makes a security operation ineffective for the lifetime of the process.
The pair is worth noting too: renaming the prefix and the lifetime together produced both results at once, a cross-tenant hit on a record that will now never be retired.
The serializer, and the denylist that stopped matching anything
Two arms in this area both end with secrets in a shared cache, and they get there by different mechanisms. The difference between them is the most useful thing in this article.
The first is the familiar one. Rename the serializer option and the library substitutes its own, which serialises the whole object. The reported serializer moved from caller-supplied to library-builtin, and the fields on the server went from four to seven, the new three being a password hash, a multi-factor secret and a set of recovery codes. A caller-supplied implementation of a guard was replaced by a weaker built-in, which is a shape this series has now measured in four unrelated libraries.
The second is new and quieter. Leave the serializer option alone, so the application's own function still runs, and rename the profile's field names instead. The serializer works by comparing each key against a list of three strings. Those strings are text in an array and nothing rewrites them; the profile's property names are names, and renaming moved them. So the comparison now matches nothing, and the fields written to the server were _0x1,_0x2,_0x3 alongside the ordinary four. The password hash, the multi-factor secret and the recovery codes are all on the shared server, under obfuscated names, and the serializer reported itself as caller-supplied throughout because it was.
That is the same shape a second area of this pass measured independently: a security rule expressed as DATA does not move when the property names it refers to move. The guard keeps running and keeps reporting itself enabled while matching nothing at all. An obfuscated field name is not protection - anything reading that cache gets the values, and the shape of a bcrypt hash or a base32 secret identifies it regardless of the key it sits under.
The arm that changed nothing inside the process, and everything outside it
The permission record's own field names are written by the application and read by the application. Renaming them changed no decision anywhere: every verdict in the run was identical, which is exactly what the rule about self-owned names predicts.
What did change is what the record looks like on the server. The stored fields went from allowed,decidedAt to _0x1,_0x2. Inside the process this is invisible and harmless. Outside it, every other reader of that cache is affected: a second service in another language, a dashboard, an on-call engineer running a query, a migration script, an export. None of them are rebuilt when you protect your bundle, and none of them will recognise the record.
So the guidance about names your own code owns needs its qualifier stated plainly: a name you own on both sides is safe inside the process and stops being safe the moment it is serialised into something that leaves it. A cache is exactly such a place, which is what makes this the most concrete demonstration of it the series has produced.
Renaming the capacity option restored an unbounded default - entries went from eight with six evictions to fourteen with none - and the option pinned to a value identical to the library default changed nothing, as predicted for the seventh consecutive pass.
What to do about it
Scope member renaming so the cache client's options object is excluded, and treat the shape of anything you write into a shared store as an interface rather than as internal detail. If a record is read by anything that is not your bundle, its field names belong outside renaming for the same reason a public API's field names do.
Two assertions catch everything measured here, and both belong at start-up rather than in a test. Ask the client for the namespace it computed and refuse to boot if it is empty. Ask it for the lifetime it will apply and refuse to boot if it is unlimited. A functional test that writes and reads back passes on every single arm in this article, because writing and reading back is what still works.
For the secrets specifically, the structural fix is to build the cacheable view of an object explicitly - a function that returns the four fields you intend to cache - rather than to remove three from a larger object by name. An allowlist of what may be cached fails closed when a name moves; a denylist of what may not fails open, which is precisely what the measurement showed.
Frequently asked questions
Does protecting my JavaScript break caching on its own?
Not in this measurement. The sample was protected on five profiles covering both output targets, the gate profile and the compressed profile, and all five behaved identically to the unprotected file. Every result required member renaming pointed at the option names.
How did one tenant end up reading another tenant's data?
The key prefix option was renamed, so the client applied its default of no prefix. Both tenants then computed the same key for their own user 42 and the cache returned the entry it had. No error is raised, because returning a stored value for a requested key is exactly what a cache does.
Would monitoring have shown anything?
Not usefully. Hit rates go up rather than down, and the returned record is well formed. The only reliable signal is asserting the namespace the client reports at start-up, or a customer noticing a name that is not theirs.
We filter secrets before caching. Why did they still get written?
The filter compares each key against a list of strings. Those strings are data and nothing rewrites them; the object's property names are names and renaming moved them, so the comparison stopped matching. The filter ran, reported itself installed, and copied everything.
Is an allowlist better than a denylist here?
Measurably. A function that builds the cacheable view from named fields produces nothing when a name moves, which fails closed. A filter that removes three fields by name copies everything when the names move, which fails open.
Renaming our own record fields changed no behaviour. Is that safe?
Inside the process, yes. On a shared server it is not: the stored record's fields became obfuscated names, so every other reader of that cache - another service, a dashboard, an export - sees fields it does not recognise. Anything serialised out of the process is an interface.
What is the recommended fix?
Exclude cache client options and the shape of stored records from member renaming, assert the computed namespace and the entry lifetime at start-up, and build cacheable views from an allowlist of fields rather than by removing secrets from a larger object.
Related reading