Safeguard
Application Security

When the Cache Key Leaks One User's Page to Another

A personalised response cached under a key that does not include the thing that made it personal. One of the few bugs that discloses one customer's data to another with no attacker involved, and it arrives as a confused support ticket.

Priya Raman
Staff Security Engineer
6 min read

A user reports seeing someone else's name in the header. You cannot reproduce it, the database is correct, and the session logic is fine. Then it happens again to a different person.

What happened is that a personalised response was cached under a key that did not include the thing that made it personal, so the first user's page was served to the second. This is one of the few bugs that leaks one customer's data to another without any attacker involvement at all, and it is a configuration mistake rather than a vulnerability in anyone's code.

This post is how cache keys go wrong and how to check yours. For whoever put a CDN in front of an application that knows who you are.

The key is the whole control

A cache decides whether two requests are "the same" by computing a key, usually from the method, host and path, plus whatever else you told it to include. If two requests produce the same key, the second gets the first one's response.

So any input that changes the response must be in the key. Miss one, and you serve the wrong body to somebody.

The inputs that commonly change a response and commonly get left out:

  • The session cookie or authorization header, for anything personalised.
  • Accept-Language, if you localise.
  • Accept-Encoding, which most CDNs handle, and custom compression which they may not.
  • The tenant, when it comes from a header or a subdomain rather than the path.
  • Feature flag state, if flags change the rendered output. This one is nearly always missed.
  • Device or viewport hints, if you serve different markup.
  • Currency or region, when derived from geography rather than the URL.

The two failure directions

Caching something personal under a generic key. The serious one. User A's dashboard is cached at /dashboard and served to user B. Data disclosure, no attacker required, and it usually surfaces as a confused support ticket rather than a security report.

Keying on something attacker-controlled. Subtler. If an unkeyed header influences the response, an attacker can poison the cached entry for everyone. If a keyed header is attacker-controlled and unbounded, they can fill your cache with useless entries and evict everything that matters.

The distinction to hold: an input that changes the response must be in the key, and an input that is in the key must be bounded and trustworthy.

The rule that prevents most of it

Do not cache authenticated responses at a shared cache at all, unless you have deliberately designed for it.

Cache-Control: private, no-store        # anything user-specific
Cache-Control: public, max-age=3600     # only genuinely identical for everyone

private tells shared caches not to store it while still permitting the browser to. The common mistake is a global public, max-age= set at the CDN or in a framework default, applied to routes that later became personalised.

That last part is where these bugs come from. The caching was configured when the page was static. Someone added a greeting with the user's name eighteen months later, and nobody revisited the cache configuration, because it was not part of the change.

Check yours

# Does an authenticated response come back cacheable?
curl -sI -H "Cookie: session=$SESSION" https://app.example.com/dashboard \
  | grep -iE 'cache-control|vary|age|x-cache'
# want: private or no-store. Any public max-age here is a finding.

# Is the response actually shared? Two different sessions, same path.
curl -s -H "Cookie: session=$SESSION_A" https://app.example.com/dashboard -o a.html
curl -s -H "Cookie: session=$SESSION_B" https://app.example.com/dashboard -o b.html
diff <(grep -o 'data-user-id="[^"]*"' a.html) <(grep -o 'data-user-id="[^"]*"' b.html)
# identical user ids from two sessions is the bug, live

# Does an unkeyed header change the body?
curl -s https://app.example.com/page -H 'X-Forwarded-Host: evil.example' | grep -c 'evil.example'

The second test is the one to run first. It takes thirty seconds and it directly demonstrates the failure rather than inferring it from headers.

The Vary header is a blunt instrument

Vary tells caches which request headers form part of the key. It is the right mechanism and it has two practical problems.

Vary: Cookie is technically correct for personalised content and effectively disables caching, because every user has a different cookie. Most people discover this, see the hit rate collapse, and remove it, which returns them to the bug.

And Vary on a header with many possible values fragments the cache into near-uselessness. Vary: User-Agent is the classic example.

So the realistic answers are to separate the cacheable and the personal rather than to make one response serve both: cache the shell and fetch the personalised parts client-side, or split into distinct URLs, or do the personalisation at the edge with a key you control.

Watch for the shape in other caches

The same reasoning applies wherever a response is stored under a computed key, and application-level caches are usually worse because nobody reviewed the key at all.

A Redis cache keyed on user_profile:{id} is fine. One keyed on profile_page and populated by whichever request arrived first is the same bug inside your own process. Grep for cache key construction and check that every input affecting the value appears in the key.

The concession

Aggressive caching is often the difference between a fast product and a slow one, and the advice to mark everything authenticated as uncacheable has a real cost at scale. Teams that serve a large amount of traffic cannot simply turn off shared caching for logged-in users.

The way through is architectural rather than a header setting: make the cacheable thing genuinely impersonal, and assemble the personal parts separately. That is more work than a Cache-Control line and it is the only version that gives you both properties. If you are not going to do that work, the safe setting is the right one.

The implication

This is a bug class where the failure is silent, the report arrives as a usability complaint, and the cause is a configuration file nobody edited when the page changed.

The thirty-second test, two sessions against the same URL, tells you where you stand. Run it against every personalised route you serve through a CDN.

Never miss an update

Weekly insights on software supply chain security, delivered to your inbox.

Self-healing security runs on Safeguard.

Your first fix PR is minutes away.

No sales call required, even your agent can complete the purchase over MCP.