HTTP Caching — Authenticated Responses Leak Through Shared Caches
Prevent private data leaking through CDN and intermediary caches, understand why authenticated responses need explicit Cache-Control private, and audit cache keys.
Symptoms
- A user sees another user data briefly, or receives cached personalized content after logout
- The CDN serves personalized JSON with HIT status to different sessions
- Reproduces only through the CDN domain; direct origin requests behave correctly
Possible causes
- Authenticated responses lack Cache-Control: private or no-store, so shared caches treat them as cacheable
- The CDN cache key ignores the Authorization header or session cookie variant
- A logout response reuses an authenticated URL without invalidating the cached variant
- An origin default (200 cacheable heuristic) applies because no Cache-Control header was sent at all
Diagnosis steps
Check what the origin actually sends
curl -sI https://origin.internal/api/me -H "Authorization: Bearer alice" | grep -iE "cache-control|vary|age"Interpretation
No Cache-Control on an authenticated response means heuristic caching applies downstream — the leak starts at the origin header, not the CDN.
Inspect the CDN cache key
curl -sI https://app.example.com/api/me -H "Authorization: Bearer alice" | grep -iE "x-cache|age|cf-cache-status" # then with bob tokenInterpretation
A HIT for the second user with the same URL proves the Authorization header is not part of the cache key or the variant selection.
Audit personalized endpoints
grep -Rn "res.json(user\|ctx.body = user" src/routes/ | headInterpretation
Every handler returning per-user content is a candidate for the private header audit.
Root cause
HTTP caches store what responses permit them to store. Without an explicit Cache-Control for authenticated content, shared caches may apply heuristic caching and serve one user personalized response to another requesting the same URL — especially when the cache key omits Authorization or the Vary header does not include the credential dimension.
Fix
Send Cache-Control: private, no-store (or private with a short max-age where genuinely safe) on every authenticated response, include Authorization in the CDN cache key or bypass CDN caching for credentialed paths, and set Vary: Authorization where variants are legitimate.
Risk note
Cache-Control: private with a long max-age still leaks across accounts on shared machines via the browser cache. Use no-store for anything sensitive.
Verify the fix
Two different sessions requesting the personalized endpoint each receive their own data with MISS (or bypass) cache status; logout responses carry no-store and are never replayed.
Symptom walkthrough
The leak has a hard reproduction boundary: through the CDN domain, user B sometimes receives user A cached response; direct to origin, always correct. The headers explain it:
$ curl -sI https://app.example.com/api/me -H "Authorization: Bearer bob"
HTTP/2 200
content-type: application/json
x-cache: HIT <- cached variant served to bob
age: 41
HIT for a personalized endpoint means the cache key does not include the credential. Ask the origin what it declared: if /api/me returns no Cache-Control at all, downstream caches may store it under heuristic rules — the bug begins in the missing header, and the CDN merely executes it.
Fix in practice
State the policy explicitly at the origin:
app.get('/api/me', auth, (req, res) => {
res.set('Cache-Control', 'private, no-store'); // per-user: never shared
res.json(req.user);
});
app.get('/api/catalog', (req, res) => {
res.set('Cache-Control', 'public, max-age=300, stale-while-revalidate=60');
res.json(catalog); // genuinely identical for everyone
});
At the CDN, keep the key honest for any credentialed path you do cache:
cache key: scheme + host + path + query + $http_authorization
# or simpler and safer: bypass cache when Authorization is present
And for authenticated variants that legitimately share a URL, Vary: Authorization makes the dimension explicit — but treat Vary on credentials with suspicion: intermediaries are historically sloppy about it, and no-store remains the safe default for sensitive payloads.