DEV Community

jeffrey
jeffrey

Posted on

A shared cache decides whether an authenticated page is private, and `Vary` is the header that says so

A shared cache decides whether an authenticated page is private, and Vary is the header that says so

Reverse proxies and CDNs are caching layers that sit in front of an application and store what it returns. When the stored object is a login page or a static asset, that is a performance win. When the stored object is a page rendered for a specific user, the same mechanism serves one person's data to the next visitor. The cache does what the response headers told it to do, and those headers are frequently the ones nobody reviewed.

What the cache is trying to decide

A cache asks three questions about a response. Is it allowed to store this? Is the stored copy still fresh? And is this request the same as the one that produced the stored copy?
The first is answered by Cache-Control directives. no-store forbids storing the response at all. private allows a browser cache but forbids a shared cache from storing it. max-age sets freshness in seconds. A response with no cache directives at all is, in practice, cacheable under heuristics that differ by implementation and are rarely documented by the team that relied on them.
The second is answered by freshness. max-age and s-maxage set explicit lifetimes, where s-maxage applies to shared caches specifically. Expires provides an absolute date. Without either, a cache may apply its own heuristic — commonly a fraction of the time since the resource last changed — and serve a stale authenticated page long after the user logged out.
The third is answered by Vary. It tells a cache which request headers were part of the response selection, so a request with a different value for that header is treated as a different object. A page whose content depends on Cookie must list Cookie in Vary if it is cacheable at all; otherwise the cache stores one user's body and serves it against every other user's request.

The two failure shapes

The first shape is a response that is authenticated but cacheable. A page rendered for a signed-in user returns 200 with Cache-Control: public, max-age=300. A shared cache in front of the application stores it, keyed by URL. The next request for that URL, from a different user, receives the stored body. The application never sees the second request, so its authorisation logic never runs.
The second shape is a response that varies on something the cache does not know about. Vary lists Accept-Encoding but not Cookie. The cache treats the object as one variant keyed by encoding and serves whichever version it stored most recently.
Both shapes require a shared cache to be present, which is why the bug class concentrates in deployments with a CDN or a reverse proxy that the application team did not configure. The fix is not always in the application: a proxy that caches aggressively by default, in front of an application that never set headers, produces the same outcome.

Where to look first

Look at responses that carry user state. Account pages, dashboards, order histories, and anything that varies by tenant are the high-value targets. Check the raw response headers with an unauthenticated request and then with an authenticated one; a difference in Cache-Control between the two is the signal worth chasing.
Look at the caching layer's own configuration. A CDN rule that caches by extension, or a proxy that caches every 200 response, overrides the application's intent for any path it matches. The effective policy is the intersection of both configurations, not the one in the repository.
Look at logout. A response that was stored before logout remains valid in the cache after logout, and nothing in the logout flow invalidates it. A POST logout that returns a redirect to a cacheable page has left the authenticated body in the cache for the remainder of its lifetime.

Controls that hold

Set Cache-Control: private, no-store on every authenticated response, explicitly, rather than relying on the default. The no-store directive is what prevents a copy from persisting in a shared cache, and it should appear on the response that carries user data, not on a wrapper.
Add Vary: Cookie where a response is both cacheable and user-influenced. This is a weaker control than no-store because it enlarges the cache key rather than preventing storage, but it removes the cross-user path where a shared cache is unavoidable.
Enumerate what the CDN is allowed to cache by path prefix rather than by default. A rule that caches static asset prefixes and passes everything else through is auditable; a rule that caches all 200 responses is not.
Test it as an attacker would. Make an authenticated request, then an unauthenticated one from a different source, and compare bodies. A test that only inspects headers confirms intent; a test that compares responses confirms behaviour.
Instrument cache hit rates on authenticated paths. A high hit rate on a page that should never be cached is the operational symptom that precedes the incident.

The review question

For each cached path, the question is what the cache key is and who else shares it. If the answer includes a header the application depends on for authorisation, and that header is not in Vary, the deployment has decided that all users see the same page. That decision is legitimate for a marketing site and a defect everywhere else.

References

Top comments (0)