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
Varysemantics, 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.