A per-request rendered page that greets the signed-in user starts showing one person's name to other visitors after a CDN was added. Why, and how do you prevent it?
answer
- the key is not the user
- method and URL, nothing else
- silence permits storing
- private versus no-store
- Vary on cookie kills hit ratio
basics
~20 sA shared cache stored one visitor's personalised render and replayed it to everyone matching the same cache key, which is usually method plus URL and ignores the session cookie. Personalised responses must declare themselves uncacheable by shared caches, or vary the key.
solid answer
~50 sThe render is per request, but caching is per cache key, and a shared cache keys on the request method and URL — not on who was signed in. If the response carries no directive forbidding shared storage, the cache is free to keep the first rendered copy and serve it to every later visitor of that URL, so the first user's greeting leaks. The fix is to make the response describe itself honestly: `Cache-Control: private` keeps it out of shared caches while still allowing the visitor's own browser to store it, and `no-store` forbids storing anywhere. If some variation genuinely must be cached, add the distinguishing input to the key with `Vary`, accepting that keying on a session cookie gives nearly one entry per user. The safer structure is to keep the shared markup shared and fetch the personal part separately.
go deeper
Learn the one sentence that explains the class of bug: a shared cache stores by URL, not by user, so a personalised response can be replayed to the wrong person.
Be able to pick the right directive and justify it — private versus no-store versus no-cache — and explain what adding a header to Vary does to the cache key.
Demonstrate the whole loop: reproduce as two users, read the headers at the edge, purge the poisoned entries, and re-test. Know that the origin behaviour did not change when the CDN appeared.
Set the default. Personalised routes should be non-shareable unless a route opts in, with a check that catches a route becoming shareable, because this failure is a data leak rather than a performance bug.
## What a shared cache actually does A cache placed between visitors and the origin — a CDN node, a reverse proxy, a corporate proxy — stores a response under a **cache key** and replays it for later requests whose key matches. Unless configured otherwise, that key is essentially the request method and the full URL. It is not the session, not the visitor, not the cookie. A shared cache is by definition one whose stored copies may be served to more than one user. That is the whole bug. Rendering per request guarantees the *render* saw this visitor's request; it guarantees nothing about who the *response* is later handed to. The moment personalised HTML enters a shared cache, everyone who matches that key gets that person's page. Two further details make this easy to walk into: - **Silence is not a prohibition.** With no explicit freshness directives, a shared cache is permitted to apply heuristic freshness to an otherwise cacheable response, and many CDNs additionally apply a default time-to-live of their own. An origin that says nothing has not opted out. - **The leak often predates the incident.** The origin behaviour did not change when the CDN was added; only the population served from one render did. The response was always mislabelled. ## The directives that decide it | Directive | Effect | Use when | |---|---|---| | `Cache-Control: private` | Shared caches must not store it; the visitor's own browser still may | The response is personal but harmless in its owner's cache | | `Cache-Control: no-store` | No cache may store it at all | The body contains sensitive material you do not want written down anywhere | | `Cache-Control: no-cache` | It may be stored, but must be revalidated with the origin before reuse | Freshness matters and revalidation is cheap | | `Cache-Control: s-maxage=<n>` | Freshness lifetime for shared caches only, overriding `max-age` for them | Shared copies should live for a different time than private ones | | `Vary: <header>` | Adds those request headers to the cache key | Responses genuinely differ by a header and each variant is reusable | A note on cookies: a response that sets a session cookie and is then stored by a shared cache can hand that cookie to strangers, so many shared caches refuse to store responses carrying `Set-Cookie` by default. That is a product policy, not a protocol guarantee, and it is not something to rely on. ## Diagnosing it 1. Request the URL as two different signed-in users and compare bodies. Identical personalised content is the confirmation. 2. Read the response headers at the edge: the `Cache-Control` the origin emitted, the age of the stored copy, and whatever hit or miss indicator the cache layer reports. 3. Request the same URL signed out. If a signed-in page comes back, the key is definitively ignoring identity. 4. Check whether the framework or hosting layer attached caching headers automatically, and whether a route that looks dynamic in the code is being treated as shareable at the edge. 5. Once fixed, purge the stored copies — correcting the header does not evict what is already in the cache. ## Keeping personalisation without the leak - **Mark it private.** The minimum correct fix: the response says it belongs to one person, and shared caches step aside. - **Vary on the distinguishing input.** Legitimate where the variation is coarse — a language or a currency header produces a handful of reusable variants. Keying on a session cookie technically works and practically destroys the hit ratio, because each user becomes their own entry. - **Split shared from personal.** Serve the parts that are the same for everyone as one shareable response and deliver the per-user fragment through a separate, uncached request. This preserves both a high hit ratio and correctness, at the cost of the personal part arriving a moment later. - **Move personal content out of the markup entirely** when it is small — a greeting or a badge count can be filled in from data the client already holds. - **Make the default safe.** Personalised routes should be uncacheable at the edge by convention, with sharing an explicit opt-in per route. Every incident of this class starts with a route that was shareable because nobody said it was not. ## Where frameworks and hosting differ Some meta-frameworks emit conservative caching headers for routes they consider request-bound, some emit nothing and leave it to the hosting layer, and some hosting layers impose their own default policy in front of whatever the origin says. Because of that, the header your code sets is not necessarily the header the edge acts on. Verify at the edge, on the deployed environment, rather than trusting the route's source.
- Why is `Vary: Cookie` a poor fix on a page with many signed-in users?It makes the cache key include the whole cookie header, so every distinct cookie value becomes its own stored entry. With per-session cookies that is roughly one entry per user, the hit ratio collapses towards zero, and the cache costs storage while absorbing almost no origin traffic.
- The header is fixed and deployed, but users still report seeing someone else's name. What now?Stored copies from before the fix are still being served. A corrected `Cache-Control` governs what caches may store from now on; it does not evict what they already hold. Purge or invalidate the affected paths at every cache layer, then re-test as two different users.
- Is `private` enough when the page contains sensitive data on a shared computer?Not necessarily. `private` still permits the visitor's own browser to write the response to disk, where the next person at that machine could recover it from history or the cache. For material that should not be persisted at all, `no-store` is the correct directive.
- How can a page stay edge-cacheable while still showing per-user content?Keep the shared markup in one shareable response and deliver the personal fragment separately — a small uncached request the client makes, or content filled in from data the client already has. The bulk of the document keeps a high hit ratio, and only the genuinely personal part goes to the origin.
A pigeonhole labelled only with the room number: whoever collects mail from it next takes whatever the previous occupant left, because the label never recorded who it was for.
saying these in an interview costs you the question
- Assumes a per-request render cannot be cached by anything
- Thinks the cache key includes the session cookie by default
- Believes a response with no caching headers is never stored
- Reaches for Vary on cookie without considering hit ratio
- Fixes the header and skips purging the stored copies
- Treats private and no-store as interchangeable