A cache sits in front of a web app to make pages fast: it stores a response once and hands the same copy to everyone who asks for the same thing. Web cache poisoning abuses that sharing. An attacker sends one carefully shaped request that makes the origin return a harmful response, gets the cache to store it under a normal key, and then every later visitor who hits that key is served the attacker’s version. One request, many victims.
Caches, cache keys, and unkeyed inputs
A cache decides whether two requests are the same by building a cache key. Most caches build that key from a small set of fields: the method, the host, the path, and sometimes the query string. If a later request produces the same key, the cache replies from storage instead of asking the origin again.
# Two requests the cache treats as identical (same key) GET /promo HTTP/1.1 Host: notes.acme.example # Cache key (simplified): GET + notes.acme.example + /promo
The trap is everything the cache leaves out of the key. Headers like X-Forwarded-Host, X-Forwarded-Scheme, cookies, or a custom header are usually not part of the key. These are unkeyed inputs. If an unkeyed input changes the response but does not change the key, the cache will happily store a response that depends on a value it ignored. That gap is the whole attack.
If an input changes the response but not the cache key, the cache will store one person’s response and serve it to the next person.
How this differs from web cache deception
These two bugs sound alike and are not. In web cache deception, the attacker tricks the cache into storing a victim’s private response (a profile page, an account API reply) so the attacker can read it. The harm flows toward the attacker. Web cache poisoning is the reverse: the attacker plants a harmful response in the cache so it is served to other users. The harm flows outward, from one attacker to a crowd.
How a web cache poisoning attack works
Take Acme Notes, a typical SaaS app at notes.acme.example behind a CDN. The origin builds some absolute URLs using the incoming X-Forwarded-Host header, so it can run behind different front ends. The CDN does not include that header in its cache key. That is the unkeyed input.
The attacker probes by sending a value they can recognize later:
GET /promo HTTP/1.1 Host: notes.acme.example X-Forwarded-Host: evil.example HTTP/1.1 200 OK X-Cache: miss Cache-Control: public, max-age=300 ... <link rel="canonical" href="https://evil.example/promo"> <script src="https://evil.example/static/app.js"></script>
The origin reflected evil.example into the page and told the cache to keep the response for 300 seconds. Because the header was unkeyed, the cache stored this poisoned copy under the plain key for /promo. Now a normal visitor asks for the page with no special headers at all:
GET /promo HTTP/1.1 Host: notes.acme.example HTTP/1.1 200 OK X-Cache: hit Age: 42 ... <script src="https://evil.example/static/app.js"></script>
The victim never sent the malicious header. They get the poisoned response because the cache is serving the stored copy. The X-Cache: hit and the rising Age value confirm the response came from cache, not the origin.
What an attacker can do with it
- Stored XSS through a reflected unkeyed header. If the origin reflects an unkeyed header into HTML without encoding it, the attacker poisons the page with a script tag or event handler. Unlike normal reflected XSS, the victim does not need to click a crafted link. They just load the page, and the cache feeds them the script.
- Redirect to an attacker site. When the origin uses an unkeyed header to build a redirect or a canonical URL, the poisoned response can point users to
evil.example. This overlaps with host header injection, since both abuse the app trusting a host value it should not. - Denial of service through a poisoned error. An oversized header or an unkeyed value that triggers a 400 or 500 can get the error response cached under a normal key. Every visitor then receives the cached error until it expires, taking the page down without touching the origin.
How to detect web cache poisoning
Detection has two halves: find the unkeyed inputs, then watch the cache react.
- Hunt for unkeyed inputs. Against an app you own, add one candidate header at a time (
X-Forwarded-Host,X-Forwarded-Scheme,X-Forwarded-For,X-Host, and any custom header the app reads) with a unique marker value. If the marker shows up in the response body, headers, or a redirect, that header influences the output. - Confirm it is unkeyed. Send the same request twice, once with the marker and once without, and compare cache behavior. Watch
X-Cache(hit or miss),Age, and anyVaryheader. If a clean request later returns your marker withX-Cache: hit, the response was cached under a key that ignored your header. That is a confirmed poison path. - Read the cache control signals. A
Varyheader tells you which request headers the cache does include in the key. If a header that changes the response is missing fromVary, it is a candidate. Use a cache buster like/promo?cb=12345in tests so you never poison a real shared key while probing.
How to prevent web cache poisoning
- Do not reflect unkeyed input into cached responses. If a header is not in the cache key, treat its value as untrusted and keep it out of anything the cache will store: HTML, redirects, canonical tags, and link or script sources.
- Key on or strip security relevant headers. If the app genuinely needs
X-Forwarded-Hostor similar, add it to the cache key withVaryor your CDN’s key settings so different values cache separately. If the app does not need it, strip the header at the edge before it ever reaches the origin. - Cache only truly static content. Pin caching to assets that do not depend on request specific input, like images, CSS, and versioned scripts. Mark dynamic pages
Cache-Control: no-storeorprivateso they are never shared. - Scope caching carefully. Avoid a broad rule that caches every 200 response. Decide per route what is cacheable, and never let error responses for one user persist under a shared key.
Why web cache poisoning rewards understanding the app
You do not find this bug by firing a fixed payload list at a target. You find it by understanding which headers the origin reads, which of them the cache ignores, and whether a value one user sends can land in a response another user receives. The flaw is an assumption: that every input affecting the response is also part of the cache key. Test that assumption directly and the gap shows itself.
That is the kind of bug an autonomous researcher that tests an app’s assumptions is built to surface, since it lives in the seam between two systems rather than in a single known payload. You can read more about that approach on our about page.
Frequently asked questions
What is web cache poisoning?
It is an attack where someone sends a crafted request that makes the origin server return a harmful response, then gets a shared cache to store that response under a normal cache key. Every later visitor who hits the same key is served the poisoned copy. The trick relies on an unkeyed input, usually a header like X-Forwarded-Host, that changes the response but is left out of the cache key.
How is web cache poisoning different from web cache deception?
They move harm in opposite directions. In web cache deception, the attacker tricks the cache into storing a victim’s private response so the attacker can read it, so harm flows toward the attacker. In web cache poisoning, the attacker plants a harmful response in the cache so it is served to many other users, so harm flows outward from one attacker to a crowd.
What is an unkeyed input?
A cache key is built from a small set of request fields, usually the method, host, path, and sometimes the query string. Any input the cache leaves out of the key is unkeyed: common examples are X-Forwarded-Host, X-Forwarded-Scheme, cookies, and custom headers. If an unkeyed input changes the response, the cache can store a response shaped by a value it ignored, which is the gap web cache poisoning exploits.
How do you detect and prevent web cache poisoning?
To detect it, add one candidate header at a time with a unique marker against an app you own, see if the marker is reflected, then check whether a clean request later returns it with X-Cache: hit and a rising Age. To prevent it, do not reflect unkeyed input into cached responses, add security relevant headers to the cache key or strip them at the edge, cache only truly static content, and scope caching per route instead of caching every 200 response.
