Vary, cache-key design, and cache poisoning

What it is

A cache stores responses under a cache key. Everything about hit rate and about a whole class of security bugs follows from what is in that key and what is not.

Default key (roughly):   method + scheme + host + path + query
Extended by Vary:        + the value of each named request header

Vary tells a cache that the response depends on a request header, so it must store a separate entry per distinct value:

Vary: Accept-Encoding
-> one entry for gzip, one for br, one for identity

Two failure directions, and they are exact opposites:

Too much in the key: the hit rate collapses. Vary: User-Agent means an entry per browser build string, of which there are effectively millions.

Too little in the key: cache poisoning. If a request header changes the response and is not in the key, one attacker's response is served to everyone.

What this is confused with: Vary as a correctness annotation only. It is a correctness annotation and the largest single lever on hit rate, and the two pull in opposite directions. The design question is: what is the smallest set of inputs that determines the response? Anything smaller poisons; anything larger fragments.

The problem it solves

Without Vary, a cache serves the wrong representation. A response compressed with Brotli, cached under a key that ignores Accept-Encoding, gets served to a client that cannot decode it. A page rendered in German gets served to an English speaker. A response containing one user's data gets served to another.

With too much Vary, the cache stops working. The measurable version:

Vary header                      distinct values seen     hit rate
─────────────────────────────────────────────────────────────────────
Accept-Encoding                  3 (gzip, br, identity)     94%
Accept-Encoding, Accept-Language 3 x 12 = 36                88%
+ User-Agent                     36 x ~1.2M                  0.3%
+ Cookie                         effectively unbounded       ~0%

Vary: User-Agent takes a 94 percent hit rate to under 1 percent, and Vary: Cookie is worse because every session cookie is unique, so every user gets a private cache entry and the cache is doing storage without ever serving a hit.

The security direction is the interesting half. Unkeyed input is any part of a request that influences the response and is not in the cache key:

Request:  GET /  with  X-Forwarded-Host: attacker.example
Response: <script src="https://attacker.example/app.js">  <- reflected
Cache:    stored under the key for "/", because X-Forwarded-Host is unkeyed
Result:   every subsequent visitor to / gets the attacker's script

One request poisons the entry for everyone. That is web cache poisoning, and the header does not need to be exotic: X-Forwarded-Host, X-Forwarded-Scheme, X-Original-URL and several others are honoured by common frameworks and ignored by common cache configurations.

Mechanics

Designing the key

Work from what genuinely determines the response:

Determines the response?          In the key?
──────────────────────────────────────────────────────────────
path, query                       yes (default)
Accept-Encoding                   yes, but NORMALISED (below)
Accept-Language                   only if you serve localised content,
                                  and normalise to your supported set
Authorization / session cookie    then the response is private:
                                  do not cache in a shared cache at all
device class (mobile/desktop)     only if the response differs;
                                  normalise to a small enum
region / country                  only if the response differs;
                                  use a normalised 2-letter code

Normalisation is the technique that makes Vary usable. Rather than varying on the raw header, normalise it at the edge into a small set and vary on that:

// Cloudflare Worker / Fastly VCL equivalent: collapse a high-cardinality
// header into a small enum BEFORE it reaches the cache key.
const ae = request.headers.get('Accept-Encoding') || '';
const normalised = ae.includes('br') ? 'br'
                 : ae.includes('gzip') ? 'gzip'
                 : 'identity';
request.headers.set('Accept-Encoding', normalised);   // now 3 values, not thousands
Raw Accept-Encoding values seen in the wild:  ~4,000 distinct strings
                                              ("gzip, deflate, br;q=1.0, *;q=0.5" etc.)
After normalisation:                          3

Four thousand distinct Accept-Encoding strings is not hypothetical: clients send different orderings, different quality values and different whitespace, and each is a separate cache entry unless normalised. Most CDNs normalise this one automatically, which is why it usually works; anything you Vary on yourself needs the same treatment.

Language, the same way:

// Accept-Language: "en-GB,en;q=0.9,fr;q=0.8" -> "en"
const supported = ['en', 'fr', 'de', 'es'];
const lang = parseAcceptLanguage(request.headers.get('Accept-Language'))
               .find(l => supported.includes(l.split('-')[0])) || 'en';
request.headers.set('Accept-Language', lang);         // 4 values, not thousands

The query-string problem

/products?id=4471                            <- the canonical URL
/products?id=4471&utm_source=twitter         <- a different cache key
/products?id=4471&utm_source=twitter&utm_campaign=aug
/products?id=4471&fbclid=IwAR2x8...          <- unique per click

Tracking parameters fragment the cache and fbclid is unique per click, so every Facebook referral is a guaranteed miss.

# Fastly VCL, or the equivalent CDN cache-key setting
sub vcl_recv {
  set req.url = querystring.regfilter(req.url,
      "^(utm_|fbclid|gclid|msclkid|mc_[ce]id|_ga|ref)");
  set req.url = querystring.sort(req.url);      # ?b=1&a=2 == ?a=2&b=1
}

Sorting the query string matters too, because ?a=1&b=2 and ?b=2&a=1 are the same request and different keys by default.

                          before      after
distinct keys for one
  product page            ~400        1
hit rate on that page     31%         97%

Cache poisoning: the mechanism

The requirements are: an unkeyed input that reaches the response, and a cache in front.

# The probe: does an unkeyed header reflect?
GET /?cb=random123 HTTP/1.1
Host: example.com
X-Forwarded-Host: canary.attacker.example

# If the response contains canary.attacker.example, it reflects.
# If a second request WITHOUT the header also returns it, it is CACHED.

The headers that commonly reach the response and are commonly unkeyed:

X-Forwarded-Host       -> absolute URLs, redirects, resource links
X-Forwarded-Scheme     -> http/https in generated links; can force a redirect loop
X-Forwarded-Port
X-Original-URL         -> routing in some frameworks (IIS, some Node middleware)
X-Rewrite-URL
X-Host
Forwarded              -> the RFC 7239 standard version

Frameworks honour these because they sit behind proxies legitimately, and caches ignore them because they are not in the default key. The gap between "the application trusts this header" and "the cache does not key on it" is the vulnerability.

Three variants worth knowing:

Cache poisoning via unkeyed header (above). One request, one poisoned entry, served to everyone.

Cache deception: the attacker gets a victim's private response cached under a public key.

Victim visits:  /account/settings.css       <- a path the app routes to /account/settings
Application:    ignores the extension, returns the victim's private page
Cache:          sees ".css", applies a static-asset rule, CACHES IT
Attacker:       requests /account/settings.css and receives the victim's data

The cause is a disagreement about what the URL means: the application does prefix routing and the cache does extension matching. Any cache rule based on a file extension is a candidate, and the fix is that cacheability must be decided by the application's response headers rather than by the URL's shape.

Cache key normalisation attacks: the cache normalises a URL differently from the origin (different handling of %2f, ;, #, trailing dots, case), so two requests the cache treats as identical produce different origin responses.

Defences

1. Cacheability is decided by the RESPONSE, not by the URL.
   No "cache everything matching *.css" rules.

2. Strip untrusted headers at the edge. If your application trusts
   X-Forwarded-Host, the edge must SET it and never pass a client's value
   through.

3. Never Vary on a high-cardinality header. If a header genuinely changes
   the response and cannot be normalised, the response is not shareable:
   mark it private.

4. Keep the cache key and the origin's routing in agreement. If the origin
   ignores a query parameter, the cache should strip it; if the origin
   routes on it, the cache must key on it.

5. Test for it. The probe is two requests: one with a canary header,
   one without.
# Strip at the edge: the application only ever sees what we set.
proxy_set_header X-Forwarded-Host   $host;        # OUR value
proxy_set_header X-Forwarded-Proto  $scheme;
proxy_set_header X-Original-URL     "";           # explicitly cleared
proxy_set_header X-Rewrite-URL      "";

Explicitly clearing headers you do not use is the part people skip, because a header you never think about is exactly the one a framework in your stack honours.

A worked example: a hit rate of 4 percent and a poisoned homepage

An e-commerce platform, about 90 million requests a day, behind a CDN.

Baseline:

CDN hit rate:               4%
origin requests:            96%
origin instances:           140
p50 latency                 410 ms

Four percent on an e-commerce site with a large catalogue meant the cache was storing and almost never serving.

Step 1: what is in the key?

$ curl -sI https://shop.example/products/4471 | grep -i vary
Vary: Accept-Encoding, Accept-Language, User-Agent, Cookie, X-Device-Type

Five headers, two of them unbounded.

distinct values observed over 24 hours:
  Accept-Encoding:  3,847      (unnormalised)
  Accept-Language:  9,214      (unnormalised)
  User-Agent:       2.4M
  Cookie:           41M        (session cookies: unique per user)
  X-Device-Type:    3

Vary: Cookie alone guarantees a zero percent hit rate for logged-in users, because every session cookie is unique. The header had been added years earlier to fix a bug where a logged-in user saw a logged-out header, and the correct fix was to not cache that response at all.

Step 2: rebuild the key from what actually determines the response.

Does the response body differ by...
  Accept-Encoding?   yes (compression)      -> keep, NORMALISED to 3 values
  Accept-Language?   yes (4 locales)        -> keep, NORMALISED to 4 values
  User-Agent?        NO                     -> remove
  Cookie?            yes, for logged-in     -> do not cache those AT ALL
  X-Device-Type?     yes (3 layouts)        -> keep, already 3 values
// At the edge, before the cache lookup.
const isLoggedIn = request.headers.get('Cookie')?.includes('session=');
if (isLoggedIn) return fetch(request);         // bypass the cache entirely

normaliseAcceptEncoding(request);              // -> br | gzip | identity
normaliseAcceptLanguage(request);              // -> en | fr | de | es
stripTrackingParams(request);                  // utm_*, fbclid, gclid
sortQueryString(request);
# And the response header
Vary: Accept-Encoding, Accept-Language, X-Device-Type
theoretical distinct keys per URL:  3 x 4 x 3 = 36     (was ~10^14)
CDN hit rate:                       4% -> 61%

Step 3: the query-string fragmentation, which was the remaining gap.

distinct cache keys for /products/4471:  ~2,900
  of which differ only by tracking params: ~2,880
after stripping and sorting:  1 key
CDN hit rate:                 61% -> 88%

Step 4: the security finding, which came from a routine scan during the work.

GET / HTTP/1.1
Host: shop.example
X-Forwarded-Host: canary.attacker.example
<!-- the response -->
<link rel="canonical" href="https://canary.attacker.example/">
<script src="https://canary.attacker.example/static/app.js"></script>

The application trusted X-Forwarded-Host for absolute URL generation, because it sat behind a load balancer that set it legitimately. The CDN did not key on it. A single request would have poisoned the homepage for every visitor with an attacker-controlled script tag.

requests needed to poison:     1
duration of poisoning:         until the entry expired or was purged (s-maxage=600)
affected users:                every visitor to that CDN PoP
severity:                      stored XSS on the homepage
# The fix, at the edge:
proxy_set_header X-Forwarded-Host $host;      # OUR value, always
proxy_set_header X-Forwarded-Proto $scheme;
# And in the application, defence in depth: an allowlist rather than trust.
ALLOWED_HOSTS = {"shop.example", "www.shop.example"}
def canonical_host(request):
    h = request.headers.get("X-Forwarded-Host", "")
    return h if h in ALLOWED_HOSTS else "shop.example"

Step 5: the cache deception check, prompted by the same review.

GET /account/orders.css
-> the application routed it to /account/orders (prefix match, extension ignored)
-> returned the LOGGED-IN USER'S ORDER LIST
-> the CDN had a rule: "cache *.css for 1 year"

A victim visiting an attacker-supplied link to /account/orders.css would have their order list cached under a public key, retrievable by anyone.

# The fix: cacheability from the RESPONSE, never from the URL shape.
- removed all extension-based cache rules
- the origin sets Cache-Control on every response; the CDN honours it
- account routes send: Cache-Control: private, no-store

Final:

                              before      after
CDN hit rate                  4%          91%
origin requests               96%         9%
origin instances              140         22
p50 latency                   410 ms      38 ms
distinct keys per product URL ~2,900      36
cache poisoning vector        present     closed
cache deception vector        present     closed
origin cost                   ~$96,000/mo ~$18,000/mo

A 4 percent hit rate was not a caching problem, it was a cache-key problem, and the same review that fixed it found two serious vulnerabilities. That pairing is not a coincidence: both come from the same question, which is what is in the key and what is not. A key that includes too much fragments; a key that excludes something that reaches the response poisons.

The transferable practice: enumerate what determines the response, then make the key exactly that. In this case the honest list was three headers with 36 combinations, and the deployed key had five headers with an effectively unbounded product, plus an unkeyed header that the application trusted.

Production evidence

James Kettle's "Practical Web Cache Poisoning" (2018) and "Web Cache Entanglement" (2020) are the reference work, and they established the unkeyed-input framing and the specific header list (X-Forwarded-Host, X-Original-URL, and others). Param Miner, the Burp extension from that research, automates the canary probe.

Omer Gil's cache deception research (2017) described the extension-based confusion, and the technique remains effective because extension-based cache rules are still common in CDN configurations.

RFC 9111 defines Vary semantics, and the specification notes that a Vary on a header with many values effectively disables caching, which is the hit-rate half of this page stated normatively.

Cloudflare, Fastly, Akamai and CloudFront all normalise Accept-Encoding automatically and all document that varying on User-Agent or Cookie will destroy the hit rate. Fastly's VCL exposes the cache key directly, which is why VCL examples are the clearest illustration of what is happening.

Cloudflare's Cache Deception Armor and similar features exist specifically because the extension-versus-routing disagreement is common enough to warrant a product feature: it verifies that a response's Content-Type matches the URL extension before caching.

The debate

Should you ever Vary on Cookie? Effectively never in a shared cache. Session cookies are unique per user, so it is a private cache entry per user, which is storage without hits. The correct expression of "this response depends on who is asking" is Cache-Control: private or bypassing the cache, not a Vary that pretends the response is shareable.

Is normalisation worth the edge complexity? Yes, and it is usually a dozen lines. The alternative is either a fragmented cache (thousands of Accept-Encoding variants) or not varying at all (serving the wrong representation). Normalisation is what makes Vary usable, and CDNs normalise Accept-Encoding for you precisely because without it the header is unusable.

Who owns the cache key? This is the organisational question underneath. The application decides what determines the response and the CDN configuration decides the key, and they are usually owned by different teams and drift apart. The failures on this page are all disagreements between those two: the application trusts a header the cache ignores (poisoning), the cache keys on a header the response does not depend on (fragmentation), or the cache decides cacheability from a URL shape the application routes differently (deception).

Should cacheability ever be decided by URL pattern? No, and this is the strongest position on the page. Extension-based and path-based cache rules are a standing invitation to deception, because the cache's interpretation of the URL and the application's routing will eventually diverge. The origin sets Cache-Control on every response and the CDN honours it, which is more work to set up and has no class of failure of this kind.

How do you test for poisoning? Two requests, and it belongs in CI rather than in an annual pentest: send a request with a canary value in each candidate header, then send a clean request and check whether the canary appears. The candidate list is short and public, so this is a fixture rather than research. A team that has never run it and is behind a CDN should assume nothing.

Is a low hit rate always a key problem? Not always (genuinely uncacheable content exists), and it is the first thing to check because it is measurable in one command. Break the hit rate down by URL pattern and count distinct keys per canonical URL: 2,900 keys for one product page is a fragmentation finding, and 1 key with a 4 percent hit rate is a TTL or purge-rate finding instead.

Follow-up Q&A

"What does Vary do and why is it dangerous?"

It tells a cache that the response depends on a named request header, so the cache must store a separate entry per distinct value of that header. That is a correctness requirement and the largest lever on hit rate, pulling in opposite directions. Vary: User-Agent means an entry per browser build string, of which there are millions; Vary: Cookie with session cookies means an entry per user, which is storage that never produces a hit. Both look like caching and neither caches.

"How do you make Vary usable on a header you genuinely need?"

Normalise at the edge before the cache lookup. Raw Accept-Encoding has thousands of distinct values in the wild because of ordering, quality values and whitespace; collapsing it to br, gzip or identity takes it to three. The same for Accept-Language, mapped to your supported locale set, and for device class as a small enum. CDNs normalise Accept-Encoding automatically, which is why that one usually works, and anything you vary on yourself needs the same treatment.

"What is cache poisoning?"

An unkeyed input that reaches the response. A request carrying X-Forwarded-Host: attacker.example where the application trusts that header for absolute URL generation and the cache does not key on it: the poisoned response is stored under the normal key and served to every subsequent visitor. One request, one entry, everyone affected. The headers are a short public list because frameworks honour them legitimately behind proxies and caches ignore them by default.

"What is cache deception and how is it different?"

Poisoning puts attacker content into a public entry; deception gets a victim's private response cached under a public key. The mechanism is a disagreement about what a URL means: the application prefix-routes /account/orders.css to /account/orders and returns private data, while the cache sees .css and applies a static-asset rule. Any extension-based or path-based cache rule is a candidate, and the fix is that cacheability must come from the response headers rather than the URL shape.

"How would you design a cache key?"

Enumerate what actually determines the response, and make the key exactly that. In one case the honest answer was three headers with 36 combinations and the deployed configuration had five with an effectively unbounded product, including Cookie. Then strip and sort the query string, because tracking parameters fragment (fbclid is unique per click) and ?a=1&b=2 versus ?b=2&a=1 are different keys by default.

"How do you test for these?"

The poisoning probe is two requests: one with a canary value in a candidate header, one clean, checking whether the canary appears in the second response. The candidate header list is short and public, so this belongs in CI as a fixture rather than in an annual pentest. For deception, request a private path with a static-looking extension and check whether the cache stored it.

Common misconceptions

"Vary is just a correctness annotation." It is also the largest lever on hit rate, and the two pull opposite ways. A Vary on a high-cardinality header is correct and disables caching.

"Vary: Cookie makes per-user caching work." It makes a private entry per user, which is storage with no hits. Cache-Control: private or a cache bypass is the correct expression.

"Cache poisoning needs an exotic header." X-Forwarded-Host and X-Original-URL are honoured by common frameworks because they sit behind proxies legitimately. The vulnerability is the gap between the application trusting them and the cache ignoring them.

"Caching static extensions is safe." Extension-based rules are the mechanism behind cache deception, because the cache's view of the URL and the application's routing diverge. Cacheability should come from the response.

"A low hit rate means the content is not cacheable." Count distinct keys per canonical URL first. 2,900 keys for one product page is fragmentation, not uncacheable content.

Interview delivery note

Say this verbatim: "The cache key is one design question with two opposite failure modes. Too much in it and the hit rate collapses: Vary: Cookie is a private entry per user, which is storage with no hits. Too little and you get poisoning: if the application trusts X-Forwarded-Host and the cache does not key on it, one request poisons the homepage for everyone." The unifying framing, with a concrete instance of each direction.

The senior-versus-staff separator is recognising that fragmentation and poisoning are the same question. A senior engineer fixes the hit rate by trimming Vary and separately treats poisoning as a security topic. A staff engineer notices both are answers to "what determines this response, and is the key exactly that," and that the same review finds both: in one case a hit-rate investigation surfaced a stored-XSS vector on the homepage and a private-data deception path.

The second signal is refusing URL-pattern-based cacheability. Saying "the origin sets Cache-Control on every response and the CDN honours it, because any extension-based rule will eventually disagree with the application's routing" shows you understand deception as a class rather than as one bug.

Further reading

  • James Kettle, "Practical Web Cache Poisoning" (2018) and "Web Cache Entanglement" (2020), for the unkeyed-input framing and the header list.
  • Omer Gil's web cache deception research (2017), for the extension-versus-routing mechanism.
  • RFC 9111 on Vary semantics, including the note that varying on a high-cardinality header effectively disables caching.
  • Fastly's VCL documentation on cache keys and querystring.regfilter, as the clearest illustration of key construction.