← Recipes

阅读中文版本

Debug Cache Variation Leaks Between Users

Prove that a shared cache is serving one user personalized response to another by testing with two identities, then fix the missing Vary header.

~25 minapicachingsecurity

Task

Demonstrate cross-user cache poisoning with two test accounts, identify the missing Vary header, and verify per-user isolation after the fix.

When to use this

A user reports seeing someone else name in the account header. The response is public, fast, and identical for everyone who tests it internally, because testers share one account.

Prerequisites

  • Two distinct test accounts with visibly different personalized fields
  • The full URL including CDN in front of the API
  • Access to response headers and CDN or origin cache configuration

Steps

1. Request the same URL as user A and record the personalized field.

curl -s https://api.example.com/me/header -H "Authorization: Bearer $TOKEN_A" | jq -r .displayName

Expected output: User A display name appears, for example Alice.

2. Request the same URL as user B without any cache-busting parameter.

curl -s -D /tmp/hdrs https://api.example.com/me/header -H "Authorization: Bearer $TOKEN_B" | jq -r .displayName

Expected output: The response shows Alice again, proving the cache served A personalized body to B.

3. Inspect the caching headers on the poisoned response.

grep -iE "cache-control|vary|age|x-cache" /tmp/hdrs

Expected output: A cache-control allowing shared caching with no Vary: Authorization explains the leak; the Age header shows the copy is minutes old.

4. Add the missing Vary header at the origin.

app.get("/me/header", (req, res) => {
  res.set("Vary", "Authorization");
  res.set("Cache-Control", "private, max-age=60");
  res.json({ displayName: req.user.displayName });
});

Expected output: The origin now declares the response varies by Authorization, so shared caches key a separate entry per credential.

5. Purge the poisoned entries and re-run the two-account test.

curl -s -D - -o /dev/null https://api.example.com/me/header -H "Authorization: Bearer $TOKEN_B" | grep -i vary

Expected output: User B now sees Bob, user A still sees Alice, and every response carries Vary: Authorization.

Troubleshooting

Problem

Vary added but the CDN still serves the old body

Solution

Entries cached before the header change remain valid until TTL expiry; purge the path or bump the cache key version after deploying the fix.

Problem

Fix destroys the cache hit rate

Solution

If the personalized part is small, split the response: cache the public shell and fetch personalization separately, so Vary only applies to the small private fragment.

Problem

Cookie-based auth leaks too

Solution

Vary on the exact cookie name used for sessions, and never let a shared cache store responses for credentialed requests without an explicit private directive.

Limitations

  • Vary multiplies cache entries by the number of distinct header values, which can shard the cache into uselessness for high-cardinality headers.
  • Testing with two accounts proves presence of the leak only for those identities; audit all personalized endpoints systematically.

Two accounts are a leak detector

Cache poisoning between users is invisible to a single tester: everyone on the team uses the same account, so the wrong personalization looks correct. The detector is two accounts and two curl calls. Request the same URL as Alice, then as Bob, without cache-busting tricks. If Bob still sees Alice name, the shared cache stored a personalized response under a public key, and the missing Vary header is the root cause.

# As user A
curl -s https://api.example.com/me/header -H "Authorization: Bearer $TOKEN_A"

# As user B — if this prints Alice, the cache is leaking
# Check the headers that allowed it
grep -iE "cache-control|vary|age" /tmp/hdrs

Vary is the cache key contract

A shared cache assumes the URL is the whole identity of a response. Vary tells it which request headers participate in the key: Authorization, Accept-Language, or a session cookie. Any endpoint whose body differs by credential without a matching Vary is a cross-user leak waiting to be cached, and the fix is one header at the origin.

Split public and private instead of giving up caching

Marking every personalized endpoint private throws away most of the cache value. The better pattern is a split response: a public, cacheable shell plus a small personalization fragment fetched with credentials and marked private. The cache hit rate survives, and the leak surface shrinks to the fragment that is actually per-user.