A CDN's whole job is to serve one stored response to many users. Web cache poisoning weaponizes that: an attacker crafts a single request whose harmful response gets cached, and the CDN then serves that poisoned response to every visitor who requests the same URL — until the entry expires.
The mechanism hinges on the cache key. A cache decides two requests are "the same" by hashing a subset of the request — typically method + host + path, maybe a query string. Everything outside that subset is unkeyed: it can still influence the response, but it doesn't create a distinct cache entry. When an unkeyed input changes the response and that response is cached, the attacker's variant is stored under the normal key and handed to everyone.
The classic recipe
Many apps reflect a header like X-Forwarded-Host when building absolute URLs (for scripts, canonical links, password-reset links). That header is usually unkeyed. So:
- The attacker requests the homepage with
X-Forwarded-Host: evil.com. - The app builds a
<script src="https://evil.com/app.js">from that header and returns the page. - The CDN caches this page under the normal homepage key (the header wasn't part of the key).
- Every subsequent visitor gets the cached homepage — now loading the attacker's JavaScript.
One request, stored XSS for the whole site. Variations abuse other unkeyed inputs: X-Forwarded-Scheme to force insecure redirects, unkeyed query parameters, X-Host, X-Forwarded-Server, and cache-key normalization quirks (a "cache key injection" via encoded characters).
What makes a response poisonable
Two conditions must both hold — which is also your map for defense:
- An unkeyed input influences the response (a reflected header, an ignored parameter).
- The response is cacheable (a cache stores it and reuses it for others).
Break either link and the attack fails.
The fix: key what matters, stop reflecting the rest
The principles
- The cache key must cover every input that changes the response. If something can alter output, either key on it or strip it before the origin sees it.
- Never reflect unkeyed, attacker-controllable headers (
X-Forwarded-Hostand friends) into HTML, links, or redirects. Use a configured canonical host and scheme. - Only cache public content. Personalized or request-specific responses get
Cache-Control: private, no-store. - Set
Varyhonestly for every header a response depends on, so caches segment entries instead of sharing a poisoned one.
The one line: if an input can change a cached response but isn't in the cache key, an attacker can poison that entry for everyone — key it or stop reflecting it.
Try it in the web cache poisoning simulation: reflect an unkeyed header to plant malicious JavaScript, watch it get served site-wide, then stop reflecting it and see the entry stay clean. It's closely related to HTTP request smuggling — both abuse the machinery between the user and your app.
Frequently Asked Questions
Related posts
Race Conditions in Web Apps: When Two Requests Beat Your Check
Check-then-act logic that looks correct on paper breaks when two requests run at once. Here's how TOCTOU race conditions drain balances and reuse one-time codes, and how atomic operations and locks fix them.
Aug 11, 2026 · 7 min readMass Assignment: How role=admin Ends Up in Your Update
Mass assignment lets an attacker set fields you never meant to expose — like isAdmin or accountBalance — by adding them to a request body. Here's why binding whole objects is dangerous and how to allowlist fields in Node, Rails, and Django.
Aug 10, 2026 · 6 min readInsecure Deserialization: How Loading Data Becomes Running Code
Deserializing untrusted data can execute code before your app reads a single field. Here's how gadget chains work in Python pickle, Java, PHP, and Node, and why JSON with a schema is the fix.
Aug 9, 2026 · 7 min read