Skip to content

The anatomy of a CDN cache key and why cache poisoning happens

· 20 min read
Copyright: MIT
The words CACHE KEY as a large monospace wordmark with one orange highlighted field and a collision arrow underneath

A shared cache exists to answer a question fast: have I already seen this request? If yes, hand back the stored bytes and skip the trip to the origin. To answer that question it cannot compare the whole request, byte for byte, because two requests that are identical in every way that matters will differ in ways that do not. Cookies change. Headers reorder. A tracking parameter gets appended. So the cache reduces each request to a short identifier, the cache key, and compares only that. Everything folded into the key is what makes two requests “different.” Everything left out is treated as interchangeable.

That second category is where the trouble lives. If part of the request changes the response the origin sends back, but that part is not in the cache key, then the cache will store one user’s response and serve it to everyone who shares the key. Feed the right input and the stored response carries your payload. This is the mechanism behind web cache poisoning, and a closely related cousin, web cache deception, runs the same discrepancy in reverse to make a cache store private data it should never have touched. Both come down to a single gap: the cache and the origin do not agree on what the request means.

This post walks the cache key from the bytes up. What goes in it by default, what an operator can add, what the Vary header does and does not do. Then the two attack families that exploit the gap, grounded in the research that named them, and the defenses that actually close it rather than paper over it. If you want the layer underneath, how a request reaches an edge node and how the cache tiers stack, that is covered separately in how a CDN actually works; here the cache key itself is the subject.

What the cache key is, per the spec

The HTTP caching spec, RFC 9111, is blunt about the minimum. The cache key “is composed from, at a minimum, the request method and target URI.” Method and URL. That is the floor every conformant shared cache builds on. A GET /pricing and a GET /about get different keys because the URI differs; a GET /pricing and a POST /pricing differ because the method differs. By default almost nothing else counts.

The URI here means scheme, host, path, and query string, normalized to whatever degree the cache decides to normalize. That last clause is doing more work than it looks, and we will come back to it, because “to whatever degree the cache decides” is exactly where cache and origin drift apart. For now the picture is simple: two requests with the same method and the same URI are, to the cache, the same request. Their responses are interchangeable. The first one to arrive populates the entry; the rest get served the copy.

An incoming request, split by what counts GET /products?id=7 HTTP/1.1 Host: shop.example Cookie: session=8fa3... X-Forwarded-Host: shop.example User-Agent: Mozilla/5.0 ... KEYED (in the key) method + host + path + ?query UNKEYED (ignored by key) Cookie X-Forwarded-Host User-Agent *The cache reduces the whole request to method, host, path, and query string. Anything not in that box, including headers the origin might still read, is unkeyed: it can change the response without changing where the response is stored.*

That asymmetry is the entire premise. A header that the cache ignores but the origin reads is the raw material of a poisoning attack. The header changes the response; the key does not change; the poisoned response lands under the clean key.

What real CDNs put in the key

The spec gives the floor. Vendors build above it, and the defaults differ enough that the same application behaves differently behind Cloudflare than behind Fastly or CloudFront. Cloudflare’s default cache key is the scheme, the host, and the URI including the query string. By default the full query string is in the key, so file.jpg?v=1 and file.jpg?v=2 are distinct entries. An operator can flip that with an “Ignore Query String” setting that drops the query entirely, collapsing both to one entry, which is convenient for assets and dangerous for anything that varies on a parameter.

What is interesting is what Cloudflare adds to the key by default beyond the URL. The Origin request header rides along for CORS correctness. So does a set of method-override and routing headers: x-http-method-override, x-http-method, x-method-override, and a cluster of forwarding headers including x-forwarded-host, x-host, x-forwarded-scheme, x-original-url, x-rewrite-url, and forwarded. That list is not arbitrary. Every one of those headers has shown up in a published cache poisoning writeup. Keying them by default is a direct response to the research, not a coincidence of design.

Beyond the defaults, Cloudflare lets an operator build a custom cache key that folds in named headers, named cookies, a device-type classification of mobile, desktop, or tablet, geographic data from the client IP, and a language derived from Accept-Language. Each addition is a tradeoff. More inputs in the key means more correct content negotiation and fewer wrong-variant hits. It also means a lower hit rate, because every distinct value of every keyed input forks a new entry, and a cache full of single-use entries is just a slow origin. Operators tune this constantly. The security-relevant fact is that the key is configurable, and a misconfiguration in either direction has consequences: too little in the key and you serve the wrong variant to the wrong user, too much and you can fracture the cache into uselessness or open a denial-of-service angle by making entries trivially unique.

The vendors do not agree on the small things, and the small things are where attacks live. A 2024 survey of cache behaviors across major providers, published as part of the Gotta cache ‘em all research, found that they split on two questions that sound trivial: do you normalize the path before keying, and which characters do you treat as a delimiter that ends the cache key. Cloudflare, Google Cloud, and Fastly did not normalize the path; CloudFront, Azure, and Imperva did. Most treated only the question mark as the delimiter that separates the keyed path from the rest; Azure used the hash. Those differences are invisible until an attacker stands a cache that normalizes against an origin that does not, or the reverse, and crafts a URL that the two read differently.

The Vary header, and why it is a partial answer

The intended mechanism for “this response depends on a request header” is the Vary response header. RFC 9111 defines it as the way a response nominates request header fields that the cache must fold into a secondary key. A response that sends Vary: Accept-Encoding tells the cache to store gzip and Brotli variants separately, keyed in part on the client’s Accept-Encoding. A cache must not serve a stored Vary response unless the nominated fields in the new request match those of the request that produced the stored copy. That is the spec’s content-negotiation machinery working as designed.

Vary is also, in principle, the fix for an unkeyed-input bug. If the origin builds its response using X-Forwarded-Host, then emitting Vary: X-Forwarded-Host promotes that header into the secondary key. Now an attacker who poisons the entry for their injected host value poisons only the entry that nobody else requests, because ordinary users do not send that header at all and land on a different key. The poison sits in a corner where no victim looks.

In practice Vary is a weak and partial defense, for reasons worth naming precisely. It depends on the cache honoring the header consistently, and CDN support for arbitrary Vary values is uneven; many caches restrict or ignore Vary on header names they do not expect. It pushes the burden onto the origin to know, for every response, every request input that influenced it, which is exactly the knowledge an unkeyed-input bug exists because the developer lacked. And Vary cannot express the most common real case at all: a cache that varies on a header tends to fracture its hit rate, so operators avoid Vary on anything high-entropy. Varying on User-Agent would shard the cache into thousands of near-duplicates. The header that would make the cache safe is often the header the operator most wants to keep out of the key for performance. Vary is the right idea aimed at the wrong layer; it asks the origin to declare its dependencies after the fact, when the durable fix is to stop the dangerous input from reaching a cacheable response in the first place.

Web cache poisoning: turning a shared cache into a delivery system

The attack that put this whole subject on the map is web cache poisoning, set out in full by James Kettle in Practical Web Cache Poisoning, presented at Black Hat USA in August 2018. The objective is stated plainly in that work: send a request that produces a harmful response, get the cache to store it, and have it served to everyone who later requests the same URL. The victim does nothing wrong. They visit the homepage. The exploit is already waiting in the cache, under the legitimate key, indistinguishable to them from the real page.

The methodology is four steps, and the order matters. First, find an unkeyed input: a header or cookie that changes the response but not the key. Second, decide whether that change is exploitable, whether you can steer it toward something harmful like injected script or a redirect. Third, get the malicious response into the cache, which means understanding when this URL is cacheable and landing your request at the right moment. Fourth, understand the cache’s behavior well enough to deliver reliably to real users rather than to a single regional edge that nobody hits.

Step one is the one that scales, and Kettle automated it. The Burp extension Param Miner guesses header and cookie names from a wordlist, sends them, and watches whether the response reflects or otherwise reacts to the guess. A header that shows up in the response body, or shifts a redirect target, or changes a Set-Cookie, is a candidate unkeyed input. The classic finding is a framework that reads X-Forwarded-Host to build absolute URLs, for canonical link tags, Open Graph metadata, or password-reset links, and reflects the attacker’s value straight into the HTML. The cache does not key on X-Forwarded-Host. The origin trusts it. The reflected attacker-controlled hostname is now baked into a cached page served to every visitor.

Attacker sends poison req Shared cache key = method+URL Origin reads X-Fwd-Host poisoned response stored under the clean key Victim normal request cache HIT poison served *The attacker's request and the victim's request share a key. The unkeyed header steers the origin's response; the cache files it under the key both requests share; every later visitor gets a hit on the poison.*

The case studies in that 2018 work are what make it concrete. The Mozilla SHIELD example is the one people remember. SHIELD was the infrastructure that pushed recipes to Firefox to control experiments and extension rollouts. By poisoning a response with a manipulated X-Forwarded-Host, an attacker could redirect Firefox’s recipe-fetching requests toward attacker-controlled infrastructure, which in principle let them replay an old recipe, including one that installed an extension with a known vulnerability, onto every Firefox browser that fetched it. A header most developers never think about, multiplied by the reach of a browser’s update channel. Other cases in the same research chained X-Original-URL against open-redirect parameters on Drupal sites to hijack JavaScript persistently, and used Cloudflare’s /cdn-cgi/trace endpoint to identify which cache location a request had landed on so the poison could be aimed at a specific region.

I will not give working payloads for any of this, and the original research is careful about the same line. The mechanism is the point. An input the cache ignores plus an origin that trusts it equals a stored response you do not control. Everything else is detail about which header, which framework, and how to make the cache hold the entry long enough to matter.

Cache key flaws: when the key itself is the bug

The 2018 work assumed a sane cache key and went hunting for inputs outside it. Kettle’s follow-up, Web Cache Entanglement, published in August 2020, turned the attention onto the key-construction logic itself. The cache key is computed by parsing, transforming, and normalizing the request, and every one of those operations is a place where the cache’s interpretation can diverge from the origin’s. When it does, you can build two requests that look the same to the cache and different to the origin, or vice versa, without ever touching an unkeyed header.

Several distinct flaws fall out of that. Cache key injection is the case where the cache fails to escape a delimiter when building the key, so a request like ?x=2 with a crafted Origin header can be made to produce the same key bytes as a request whose query string carries an injected payload; the two collide, and the attacker’s payload gets served under the victim’s key. Parameter cloaking is the inverse trick against caches that try to exclude a parameter such as utm_content from the key: a parsing quirk, like Ruby on Rails treating a semicolon as a parameter delimiter where the cache does not, lets ?param1=test;param2=foo and ?param1=test&param2=foo diverge so that the excluded-parameter logic can be steered.

Then there is the fat GET, a request that is a GET but carries a body. Some stacks forward the body to the origin, the origin reads parameters from it, and the cache keys only on the URL and never on the body. GitHub was vulnerable to this: a GET to an abuse-report endpoint with a form-encoded body could inject a parameter that the cache never saw, poisoning the entry. Normalization discrepancies are the broadest category. nginx fully URL-decoded the path when computing the cache key while forwarding the still-encoded path to the backend, so an encoded %3f in the right place created a cache collision against legitimate Firefox update requests and could disable browser updates globally. Cloudflare excluded the port from the key, which let a homepage be persistently redirected toward a non-existent port. Each of these is the cache and the origin disagreeing about what the bytes mean.

One request, two readings GET /account;junk/..%2fstatic/x.js Cache reads no ';' delimiter, normalizes ..%2f key = /static/x.js Origin reads ';' ends the path, serves the account page dynamic response dynamic response stored under a static key, served to all *A delimiter the origin honors and the cache ignores, combined with a path traversal the cache normalizes, makes one request mean two things. The cache files a private dynamic page under a public static key.*

Web cache deception: the same gap, run backwards

Poisoning gets the cache to store an attacker’s content for everyone. Web cache deception runs the discrepancy the other way: it gets the cache to store a victim’s private content where the attacker can fetch it. The technique was named by Omer Gil in February 2017, in a writeup that demonstrated it against PayPal. The recipe is short. Caches are usually configured to store responses for paths that end in a static extension, .css, .js, .jpg, often by ignoring the response’s own cache-control headers and trusting the extension instead. So you take an authenticated, sensitive page, append a fake static filename to its path, and lure the victim into loading it while logged in.

The origin, depending on how it routes, treats the extra path segment as decoration and returns the sensitive dynamic page, the account-overview HTML with the victim’s data in it. The cache sees a URL ending in .css, decides this is a stylesheet, and stores the response, headers be damned. Now the private page sits in a shared cache under a key that ends in .css, which has no session binding and which the attacker can request directly. Two conditions must both hold: the cache stores by extension while ignoring caching headers, and the origin returns dynamic content for the decorated path. Both are common enough that the attack works in the wild.

It works often enough to be a measured problem, not a theoretical one. The USENIX Security 2020 study Cached and Confused tested the Alexa Top 5,000 and found 340 sites vulnerable to web cache deception, many of them leaking session identifiers and authentication tokens that an attacker could lift straight out of the cache and replay. That paper, and its 2022 follow-up Web Cache Deception Escalates, also widened the path-confusion surface beyond Gil’s trailing-extension trick, showing that encoded delimiters and path-parameter variations let an attacker reach sites the original technique missed. The 2024 Gotta cache ‘em all research by Martin Doyhenard pushed the same idea further, turning the focus from rarely-visited URLs to poisoning the static resources every visitor loads, by exploiting exactly the normalize-or-not and delimiter differences between cache and origin catalogued earlier.

The thing to hold onto is that poisoning and deception are not two attacks. They are one structural defect, the cache and the origin disagreeing about a request, exploited in two directions. In poisoning the disagreement is about which inputs shaped the response. In deception it is about whether the response is cacheable at all. Close the disagreement and both attacks die at once.

Defenses that actually close the gap

The defenses that hold up are the ones aimed at the disagreement itself, not at any particular header. The cleanest is to not cache responses that depend on the user. Anything carrying session-specific data should carry Cache-Control: no-store or at least private, and a shared cache that honors those directives will refuse to store it, which kills web cache deception for that response outright. RFC 9111 defines no-store as a hard prohibition on storage by both private and shared caches, and private as a restriction to private caches only. The failure mode that deception relies on is exactly a cache that ignores these directives because it trusts the file extension instead, so the corollary defense lives at the cache: do not let extension-based caching rules override the response’s own caching headers. If the origin says do not store, the edge does not store, full stop.

The second defense is to make the cache and the origin agree on the request before either acts on it. A CDN should normalize the path the same way the origin will, or, better, reject ambiguous requests rather than guess. If the origin treats a semicolon as a path delimiter, the cache must either treat it the same way or refuse to serve the request from cache. Several CDNs now expose normalization controls precisely so an operator can align the two. The durable version of this is to strip the dangerous inputs at the edge: remove X-Forwarded-Host and the family of forwarding and override headers from inbound requests unless the architecture genuinely needs them, so they never reach an origin that might trust them. Cloudflare’s decision to fold that exact header set into the default cache key is the same defense expressed differently, making the header keyed so a poisoned variant cannot collide with the clean one.

Third, audit for unkeyed inputs the way an attacker would find them. The Param Miner approach, guessing header names and watching for a response that reflects or reacts, is a defensive tool as much as an offensive one; running it against your own application surfaces the headers your framework trusts before someone else does. Where you find one, the choice is to stop the origin trusting the input, or to key it, or to mark the response uncacheable. Vary is available for the cases it fits, but treat it as the last and weakest option, not the first, because it depends on the cache honoring an arbitrary header name and on the origin enumerating dependencies it probably does not fully know. For the anycast and tiered-cache mechanics that decide which edge node holds a given entry, and therefore how widely a single poison spreads, the CDN architecture post covers the layer this all sits on.

The shape of the whole problem

Strip away the header names and the framework quirks and a cache key is one design decision made under pressure: which parts of a request are allowed to matter. Every byte you let into the key buys correctness and costs hit rate. Every byte you leave out buys speed and risks serving the wrong thing. The vendors landed on slightly different answers to that question, and the gaps between their answers, and between any cache’s answer and the origin’s, are the entire attack surface. Poisoning lives in the inputs the key ignores but the origin reads. Deception lives in the cacheability decision the cache and origin disagree on. Both are the cache being confidently wrong about what two requests have in common.

That is why the genuinely strong defenses are about agreement, not about any single header. Make the cache and the origin parse the same request the same way, refuse to cache what depends on the user, and strip the inputs that one trusts and the other ignores. The header lists in the research age fast; new frameworks invent new trusted inputs every year, and the next X-Forwarded-Host is already shipping in some default config. What does not age is the structural fact. A shared cache is a bet that two requests are interchangeable, and the bet is only as safe as the agreement between the machine making it and the machine that generated the response. When 340 of the top 5,000 sites were leaking session tokens out of their caches years after the technique was published, the lesson was not that one header was dangerous. It was that the agreement is hard to get right and easy to assume.


Sources & further reading

  • Kettle, J. (2018), Practical Web Cache Poisoning — the Black Hat USA 2018 research that defined unkeyed inputs and the four-step poisoning methodology, with the Mozilla SHIELD and Drupal case studies.
  • Kettle, J. (2020), Web Cache Entanglement: Novel Pathways to Poisoning — cache key injection, parameter cloaking, fat GET requests, and normalization discrepancies that make the key itself the bug.
  • Doyhenard, M. (2024), Gotta cache ‘em all: bending the rules of web cache exploitation — the 2024 survey of per-vendor path normalization and delimiter behavior, and escalation of deception into static-resource poisoning.
  • Gil, O. (2017), Web Cache Deception Attack — the original writeup naming web cache deception, demonstrated against PayPal via appended static file extensions.
  • Mirheidari, S. A. et al. (2020), Cached and Confused: Web Cache Deception in the Wild — USENIX Security study measuring 340 vulnerable sites in the Alexa Top 5,000 and the path-confusion variations that extend the attack.
  • Mirheidari, S. A. et al. (2022), Web Cache Deception Escalates! — USENIX Security follow-up widening the path-confusion surface and re-measuring prevalence.
  • IETF (2022), RFC 9111: HTTP Caching — the normative definition of the cache key (method plus target URI), the Vary secondary key, and the no-store, private, no-cache, and s-maxage directives.
  • Cloudflare (2024), Cache keys — vendor documentation of the default cache key, the forwarding and override headers keyed by default, and the custom cache-key options.
  • PortSwigger (2024), Web cache poisoning — the Web Security Academy reference on the attack class, with labs and a structured taxonomy of unkeyed-input bugs.
  • PortSwigger (2024), Web cache deception — the companion reference covering path-confusion mechanics and the conditions both cache and origin must meet.

Further reading