- Published on
Put the tenant in the cache key, not just the record ID
- Authors

- Name
- Mehdi Akiki
Article · Derived state
Two customers both have a provider record called 42. The database stores them separately. The cache uses only 42 as its key.
The second customer's request can now receive the first customer's cached record without reaching the database at all.
This is why identity scope needs to survive every storage layer. A correct table key does not automatically become a correct cache key.
Use a structured key
If external IDs are unique inside a provider account, include that account and its tenant. Rust can make the dimensions explicit:
#[derive(Clone, Debug, PartialEq, Eq, Hash)]
struct CacheKey {
tenant_id: String,
provider_account_id: String,
external_record_id: String,
}
Construct tenant_id from authenticated server context. A request body that names another tenant must not choose the scope merely by supplying the desired cache key.
If several providers can exist inside the same account namespace, include the provider identity too. The right dimensions follow the real identity contract, not the shortest key you can construct.
The identity-mapping article explains those namespaces in stored mappings. The same reasoning applies to a cache even when its entries expire quickly.
Avoid ambiguous string concatenation. A structured key is clear in an in-process map. A distributed cache needs an unambiguous encoding with bounded parts and an explicit version when the format changes.
Scope invalidation the same way
A correct read key paired with a record-ID-only invalidation path is still incomplete. It can evict another tenant's entry or leave the intended entry stale.
Carry the same key through lookup, fill, refresh, deletion, and background repair. Include negative-cache entries too: “record not found” is a scoped result, not a globally reusable fact about an ID string.
If content depends on the user's permissions inside a tenant, tenant scope may still be too broad. Cache an appropriately authorized representation, include the necessary policy dimension, or recheck access before returning the cached data.
Do not place raw credentials or sensitive input in cache keys. Keys can appear in diagnostics and infrastructure tooling.
Keep the database boundary as well
The row-security example protects database access under the intended application role. Keep that protection. It does not inspect data already returned from an application cache.
OWASP's authorization guidance applies to each request regardless of the source of the response. A cache hit is a faster data path, not an exemption.
Make IDs collide in the fixture
Create two tenants with the same external ID and different visible values. Warm tenant A's cache, then request the record as tenant B. Repeat in the other order.
Assert isolated values, scoped invalidation, and correct behaviour for missing records. Add the case where one user loses access while an entry remains warm.
Some data is intentionally public and shared. A global key can be right for that contract. Document it so the same helper is not reused for private customer records by accident. For scoped data, the safest useful habit is to keep the full identity together wherever it travels.
A warm cache still goes through current authorization
The integration fixture sends requests through an Axum router backed by PostgreSQL and an in-process cache. Tenant A and tenant B both have an external record numbered 42. A second provider account under A also has 42. These collisions are intentional; globally unique fixture IDs would make an incomplete key look correct.
Reads derive the tenant from the fixture identity, check the requested provider account against current database permissions, and only then inspect the cache. The key includes tenant, provider account, and external record ID. Warming one account's entry cannot satisfy another account's request.
Invalidation uses the same structured key. The test changes one stored value, invalidates its authorized entry, and checks that another account's warm value remains. It also exercises a negative entry for an absent record. “Not found” is cached within the same scope; it is not a global fact about an external number.
Then the test revokes an account permission in the database while a value is already cached. The next request is refused. That is a useful distinction: a correctly scoped cache key can prevent accidental collisions, but it cannot replace checking whether the caller still has access.
The full router test uses a fixed token registry to isolate these decisions. It does not test JWT verification, distributed invalidation, cache expiry across replicas, or a real provider's consistency rules. Those are additional boundaries when moving the pattern into a deployed integration.
Keep that scope clear in the product. A cache hit means a saved representation was found under a key. It does not mean the provider is current, the user is authorized forever, or another replica has seen an invalidation. Define those contracts separately, and retain collision and revocation cases so later cache optimizations cannot quietly remove them.