Backend
A Fast Cache Hit Can Still Return the Wrong Tenant’s Data
By Vivek Jalondhara · 4 min read
Published
A tenant-aware cache key is part of data isolation. Follow a hypothetical CRM request through identity, authorisation, and cached representation.
A dashboard becomes faster after adding a cache. Every response still contains valid JSON, and the status codes look healthy. Then an account switch reveals a contact belonging to another workspace. The performance improvement concealed a broken identity boundary.
This is a hypothetical SmackDab-style CRM exercise, not an incident report about the project. Give two synthetic tenants a record with the same local identifier, warm the cache for one tenant, and consider what the next request is actually allowed to reuse.
Reconstruct the wrong hit
Suppose North has contact 42 named Mira, while South has contact 42 named Arun. A database query includes the tenant identifier, but its cache key is only contact:42. North populates that entry. South subsequently receives Mira because the cache path bypasses the correctly scoped query.
The important observation is where the identity became incomplete. The database can be perfectly isolated while the optimization above it silently combines two different resources. A passing cold-cache test exercises the database query and never visits the problematic branch.
Make the cache identity explicit
OWASP’s multi-tenant guidance calls for tenant-aware cache keys and access checks around cached data. Apply that principle to a concrete representation: a raw contact record, a translated summary, and a permission-filtered response may require different keys.
For this exercise, cache only the tenant-scoped raw record inside the backend. Encode a version label, trusted tenant identifier, and record identifier as a JSON array. MDN documents JSON serialization; using a fixed array of validated strings preserves the boundaries between these components. It avoids ambiguity when an identifier itself contains a colon.
Keep permission outside the hit decision
A matching key answers which stored object the backend found. It does not answer whether the current caller may read that object. Resolve the caller’s tenant membership through the authentication and authorisation path, then enforce record access and field visibility before returning a response.
Imagine a manager and a contractor in North. They share a tenant but may see different contact fields. Reusing a manager’s already-rendered response for the contractor is unsafe even when tenant isolation is correct. In the proposed design, apply each caller’s permitted projection after retrieving the internal raw record.
Separate age from ownership
Redis describes cache-aside as looking in the cache before loading missing data from the primary store. That retrieval pattern does not establish who owns the stored value. A short expiration time would only shorten this example’s exposure window; it would not repair the missing tenant component.
Treat an update to Arun’s telephone number and a request for Mira’s record as different concerns. The first requires a freshness policy. The second requires isolation. Write separate acceptance conditions so a successful invalidation test cannot stand in for an authorisation test.
Test the warm path deliberately
Create North and South fixtures with the same contact identifier but unmistakably different names. Request North, then South, then North again. Repeat in the reverse order. Assert the returned tenant and allowed fields, not merely the response status or the number of database queries.
Add a membership-change case according to the application’s documented revocation policy. Also test a user switching workspaces in one browser session. Clear or correctly partition the browser’s own cached views, because a safe backend key cannot fix a frontend store that still indexes contacts only by local identifier.
Make evidence safe to inspect
During diagnosis, record a request correlation identifier, the key format version, the relevant authorisation outcome, and whether the cache was used. Avoid placing contact details in logs just to prove the bug. Synthetic fixtures should make ownership distinguishable without exposing real customer records.
If a suspect shared entry already exists, deploying a corrected key builder does not remove every other path that can read it. Review all readers and writers, stop using the old namespace, and decide how to retire those entries. The completed exercise should explain both why the old hit was wrong and why the new hit is eligible.
Build an unambiguous internal cache key
Educational JavaScript for a tenant-scoped raw-record cache. Pass tenant identity resolved by the server, not an unchecked request header.
Illustrative example · JavaScript · Simplified for explanation
export function crmRecordKey(tenantId, recordId) {
const parts = [tenantId, recordId];
if (parts.some(value => typeof value !== "string" || value.length === 0)) {
throw new TypeError("Cache identifiers must be nonempty strings");
}
return JSON.stringify(["crm-record:v1", tenantId, recordId]);
}- Resolve authorised tenant context
- Build the scoped record key
- Retrieve cache or scoped database row
- Enforce record and field access before responding
Conceptual flow for the example above.
Tradeoff to keep in mind
This function only encodes identity. It provides neither authorisation nor freshness, and assumes identifiers have already passed the application’s format and size checks.