Cache Key Design for Personalised Responses
Personalised responses are usually declared uncacheable, which is correct for the whole response and wasteful for the parts of it that are identical for everyone. This page covers how to split a response into cacheable and personal parts, how to design a key that shares what can be shared, and the two mistakes that turn a cache into a data-leak mechanism.
Prerequisite concepts
This assumes the key construction and Vary material in caching and response optimization and the tier model described there.
What is actually personal
The practical consequence is that “personalised” is a property of a few fields rather than of the endpoint, and the highest-value change is usually to separate them into two requests: a cacheable resource and a small personal overlay the client merges.
Designing the key when you cannot split
Where splitting is not possible, the key must include exactly the dimensions that change the response — no more.
-- Kong 3.x — a cache key from the dimensions that actually vary the response
local function cache_key(conf)
local jwt = kong.ctx.shared.jwt_claims or {}
local tier = jwt.pricing_tier or "public" -- 4 values, not 40,000 users
local region = kong.request.get_header("cf-ipcountry") or "xx"
local path = kong.request.get_path()
local query = normalise_query(kong.request.get_raw_query())
return ngx.md5(table.concat({ path, query, tier, region }, "|"))
end
The substitution of pricing_tier for a user identifier is the whole technique. A key including the user identifier gives each user their own copy and a hit rate near zero. A key including the pricing tier gives four buckets shared by everyone in each tier, with an identical response in each. The condition is that the tier must fully determine the response — if two users in one tier can see different prices, the key is wrong and the cache will serve one of them the other’s data.
The two mistakes that leak data
Deriving the key from a header the caller sets. If the key includes x-pricing-tier and the client can send it, any client can read any tier’s cached response by asserting it. Derive every key component from a verified token claim, and strip the corresponding headers on the way in.
Caching a response whose Cache-Control says private. A shared cache honouring private is not optional. Where the gateway sets the header itself after transformation, make sure the caching filter runs after the header is set and reads the final value.
# Kong 3.x — cache only what is safe, and be explicit about it
plugins:
- name: proxy-cache
route: product-detail
config:
strategy: memory
cache_ttl: 60
response_code: [200]
request_method: ["GET", "HEAD"]
content_type: ["application/json"]
vary_headers: [] # nothing from the caller varies the key
vary_query_params: ["fields", "currency"]
# the tier component is added by a preceding plugin from a verified claim
Decision matrix
| Response shape | Approach |
|---|---|
| Personalisation is a few fields | split into a cacheable resource plus a personal overlay |
| Response varies by a small set of classes | key on the class, derived from a verified claim |
| Response is unique per user | do not cache at the shared tier; cache in the client |
| Personalisation is a reorder of shared items | cache the items, personalise the order client-side |
| Contains anything confidential to one user | no shared cache, no exceptions |
Gotchas and failure signals
A hit rate that is high but the wrong content is the failure this design risks, and it does not appear on any dashboard. Test with two users in the same class and compare responses byte for byte in CI.
Tier membership changes and the cache does not know. Keep the tier’s cache lifetime short enough that a customer upgraded this morning sees the new price today, or invalidate on the tier change event.
A cached 401 or 403 is a denial-of-service against one user. Restrict the cacheable status codes to those you intend, as in the configuration above.
Vary on a header you also key on doubles the dimension and halves the hit rate for no benefit.
Validation
- Every key component derives from a verified claim, never from a caller-set header
- Two users in the same class receive byte-identical responses, asserted in CI
- Only 200 and 404 are cacheable, and only for safe methods
- Class lifetime short enough that a membership change is visible the same day
-
privateresponses verified not to enter the shared tier - Hit rate and entry count monitored per route, so a key change is visible
FAQ
Can a personalised response be cached at all?
Usually only in parts. In a typical response the personal fields are a few hundred bytes and everything else — names, descriptions, images, specifications, list prices — is identical for every caller. Splitting into a cacheable resource plus a small personal overlay that the client merges recovers almost all the benefit, and is a bigger win than any key-design trick.
How do I key a response that genuinely varies per user?
Key on the class that determines the response rather than on the user. If four pricing tiers fully determine the price, the key needs the tier and not the user identifier: four entries instead of forty thousand, and a hit rate above ninety percent instead of near zero. The condition is strict — if two users in one tier can see different content, the key is wrong.
What is the most dangerous mistake here?
Deriving a key component from a header the caller can set. If the key includes a pricing-tier header that clients send, any client can read another tier’s cached response by asserting it. Every key component must come from a verified token claim, and the corresponding inbound headers must be stripped at the edge.
How do I prove the cache is not serving the wrong user’s data?
With a test rather than a dashboard, because a hit rate cannot tell you the content was right. Fetch the same resource as two different users in the same class and compare the bodies byte for byte in CI. Run the same test for two users in different classes and assert the bodies differ.
Parent: Caching & Response Optimization
Related
- Caching & Response Optimization — key construction, tiers and invalidation in general.
- Edge Caching: Varnish vs Gateway-Native Cache — where a shared cache sits and what it may store.
- Security Boundaries & Zero Trust — why a caller-set header must never be trusted as a key component.