Skip to content
pwnsy
web-securityadvanced#cache-poisoning#web-security#owasp#http#appsec

Web Cache Poisoning: Cache Keys, Unkeyed Inputs & Defense

How web cache poisoning turns a shared cache into a delivery system for attacker content by abusing unkeyed inputs, and how to defend the cache key.

Caches exist to serve the same content to many people cheaply. That is their purpose and, from a security standpoint, their hazard. If an attacker can convince a shared cache to store a response of their choosing under a URL that ordinary users request, the cache does the attacker's distribution for them. One malicious request goes in, and a poisoned response comes out for every visitor who asks for that page until the cache entry expires. This is web cache poisoning, and it turns a performance layer into an attack multiplier.

The attack rewards patience and precision. It depends on understanding one specific idea, the cache key, and on finding the inputs the cache forgot to include in it.

The cache key is the whole game

When a request arrives, a cache has to decide whether it already holds a response that will do. It answers that by computing a cache key from selected parts of the request. Typically the key is the request method, the host, and the path, sometimes with a specific query string or a small set of headers added. If a new request produces the same key as a stored response, the cache serves the stored copy without asking the origin server.

Everything the cache puts into the key is keyed. Everything else is unkeyed. Unkeyed inputs are the crux of cache poisoning. If a request element is unkeyed but still changes the response the origin generates, then two requests that the cache considers identical can produce different responses, and the cache cannot tell them apart. An attacker sends a request whose unkeyed input produces a malicious response, the cache stores it against the normal key, and it then hands that malicious response to real users whose requests match the same key.

A concrete walkthrough

Suppose an application generates absolute URLs in its pages (for scripts, links, or redirects) using the X-Forwarded-Host header, which is a common pattern behind proxies. The cache, meanwhile, keys only on host and path and ignores X-Forwarded-Host entirely. That header is unkeyed but response-affecting, the exact combination the attack needs.

An attacker sends a request for the home page with X-Forwarded-Host: evil.example. The origin dutifully builds the page with resource URLs pointing at evil.example, for instance a script tag loading JavaScript from the attacker's server. The cache stores this response under the ordinary home-page key, because as far as it can see this was a normal request for the home page. From that moment, every user who loads the home page is served the cached copy, and their browser fetches and runs the attacker's script. A single request has become stored cross-site scripting delivered to the whole audience of that page.

One request, many victims

What makes cache poisoning dangerous is reach. A reflected vulnerability normally harms only the user who clicked the crafted link. Route it through a shared cache and the same payload is served to everyone who requests the poisoned resource, with no link to click. The blast radius is the cache's entire audience for that entry, for as long as the entry lives.

The families of unkeyed input

SourceExampleWhy it poisons
Forwarding headersX-Forwarded-Host, X-Forwarded-SchemeReflected into links and redirects, ignored by the key
Custom application headersApp-specific headers that toggle behaviorChange the response body but are not keyed
Unkeyed query parametersParameters the cache strips before keyingAlter the response yet collapse to the same key
Cache key normalization quirksCase, encoding, or trailing-character handlingLet an attacker hit the same key as victims by a path the origin treats differently

Two conditions must hold together for any of these: the input must change the response, and the cache must exclude it from the key. Where both are true, the resource is poisonable.

What an attacker can deliver

Cache poisoning is a delivery mechanism, so its ceiling is set by what the unkeyed input lets the response do:

  • Stored XSS, when an unkeyed header or parameter is reflected into the page without proper encoding.
  • Malicious redirects, when the poisoned response sends users to an attacker site, effectively a cache-delivered open redirect.
  • Defacement or content injection, when reflected input changes visible page content.
  • Denial of service, when a crafted request makes the origin return an error that the cache then stores and serves for a legitimate URL, a variant known as cache-key denial of service.
  • Loading of attacker resources, when reflected host or scheme values point scripts, styles, or imports at attacker-controlled origins.

It also pairs with HTTP request smuggling: a smuggled request can force a poisoned entry into the cache even where the cache would not otherwise accept the attacker's input directly.

Why this is easy to miss

The vulnerable request often looks completely normal in isolation. The attacker adds one header most tools do not display, gets an ordinary-looking response, and the poisoning only becomes visible when a different user, later, on a different request, is served the stored copy. Testing requires sending the crafted request, then a clean request for the same key, and checking whether the clean request receives the poisoned response. Missing that second step hides the whole class of bug.

How to defend against cache poisoning

Every durable control comes back to one rule: nothing that changes the response may be left out of the cache key, and nothing untrusted should change a cached response.

  1. Key on every input that affects the response. If a header or parameter can change what the origin returns, either include it in the cache key or stop the origin from varying on it. The Vary response header is the standard way to tell caches which request headers matter.
  2. Strip unkeyed request headers at the edge. Remove headers like X-Forwarded-Host and other forwarding or custom headers at the cache or CDN before they reach the origin, unless the origin genuinely needs them and they are keyed. If the origin never sees the unkeyed input, it cannot reflect it.
  3. Do not build responses from untrusted request headers. Generate absolute URLs, redirects, and links from server configuration, not from client-supplied host or scheme headers. This removes the reflection at the source, which also fixes the related host header injection risk.
  4. Encode all reflected output. If any input does reach the response, contextual output encoding stops it from becoming XSS even if a resource is poisoned. Cache poisoning turns a reflection bug into a broadcast, so removing the reflection removes the broadcast payload.
  5. Do not cache what should not be cached. Mark responses that depend on untrusted or per-user input as uncacheable with explicit Cache-Control directives, so they never enter a shared cache in the first place.
  6. Normalize cache keys carefully. Ensure the cache and origin agree on path casing, encoding, and trailing characters so an attacker cannot poison the key victims will hit by exploiting a normalization gap.
Align the key or remove the input

For every cache in front of your application, ask two questions of each request element: does it change the response, and is it in the key. If the answer is yes-and-no, you have a poisoning primitive. Fix it by putting the input in the key, or by stripping the input before the origin can react to it. One of those two must be true for every response-affecting field.

Where caches live and why it matters

The word cache covers several very different layers, and the poisoning risk looks different at each one. Knowing where a response is stored tells you who is affected and how long the poisoning lasts.

A content delivery network sits at the edge, close to users, and caches responses for a whole region or the whole internet. Poisoning an entry here has the widest blast radius, because every user routed to that edge node receives the stored copy. CDNs also tend to key aggressively on just host and path for performance, which is exactly the condition that leaves headers unkeyed.

A reverse proxy in front of the origin caches on behalf of the application for all its users. Poisoning it affects everyone who hits that proxy for the resource. This is the classic shared-cache target described throughout this guide.

A load balancer or gateway may cache selectively, and any caching layer inside the request path counts, including ones teams forget are there.

The browser cache is per-user and private, so a poisoned entry harms only that one user. It matters far less for a broadcast attack, though it can still hold a bad response longer than expected for the individual.

The important operational point is that there is often more than one cache in the path, and they may key differently. A response can be uncacheable at the origin's intent yet still stored by an intermediary that reads the headers differently. Every cache between the user and the origin is a place the key and the response must agree, and a mismatch at any one of them is a poisoning primitive.

How to test for cache poisoning

Because the vulnerable request looks ordinary in isolation, testing has a specific rhythm that separates it from most reflection testing. The method comes down to proving that an unkeyed input reaches other users.

  1. Find inputs that affect the response. Add candidate headers to a request one at a time, for example forwarding headers or custom application headers, and watch whether the response changes. A value that appears reflected in the body, a link, or a redirect is a candidate.
  2. Confirm the input is unkeyed. Send the same request without your crafted input and check whether the cache serves your earlier, modified response anyway. If a clean request receives the response shaped by your header, the input is unkeyed and the response was cached against the normal key.
  3. Use a cache buster while probing. Add a unique, harmless query parameter during testing so you do not poison the real entry that live users hit. This lets you study the behavior without harming anyone, and you remove the buster only when you deliberately want to prove full impact in a controlled test.
  4. Look for the cache's own signals. Response headers that indicate a hit or a miss, and age headers, tell you whether your response was stored and served from cache. Watching these confirms each step.
  5. Prove reach. The finding is real when a request with none of your crafted input, matching the key a normal user would produce, receives the poisoned response. That is the whole vulnerability: an ordinary request served attacker-shaped content.

Skipping the second and fifth steps is the most common reason this class of bug is missed. A reflected header alone is not cache poisoning. The proof is that a clean request, later, gets the poisoned copy.

Cache buster during testing, then prove the real key

Always probe with a unique cache-buster parameter so your experiments do not poison the entry real users receive. Once you understand the behavior, the finding is only confirmed when you demonstrate, carefully, that a request matching the genuine key returns your content. The buster protects users while you learn; removing it in a controlled check is what proves the impact is real rather than confined to your own throwaway key.

Cache poisoning versus cache deception

The two attacks sound alike and are frequently confused, so it helps to separate them cleanly.

AspectWeb cache poisoningWeb cache deception
GoalServe attacker content to other usersTrick the cache into storing a victim's private content
Who is harmedEveryone who requests the poisoned resourceThe victim whose sensitive response gets cached
Direction of the trickAttacker shapes a response stored for othersAttacker causes a victim's response to be stored and then reads it
Root causeUnkeyed input affects a cached responseCache stores a response it should have treated as private
Typical fixAlign the key with response-affecting inputsDo not cache authenticated or dynamic responses; match cache rules to content type

In poisoning, the attacker pushes content out to the crowd. In deception, the attacker pulls a specific victim's private content into a cache they can then read. Both come from the cache and origin disagreeing about what may be stored and under what key, which is why they are cousins, but the mechanics and the fixes differ.

Common mistakes and misconceptions

Treating a reflected header as harmless because it needs a header most users never send. Through a shared cache, the attacker sends the header once and everyone else receives the result without sending anything. The rarity of the input is irrelevant when the cache does the distribution.

Assuming HTTPS prevents it. Transport encryption protects data in transit. Cache poisoning happens at the application and cache layer, on content the cache is entitled to store and serve. The connection can be perfectly encrypted while the cached content is malicious.

Testing only the crafted request. Sending the header and seeing it reflected proves reflection, not poisoning. Without the follow-up clean request that receives the stored copy, you have not shown the response reaches other users, which is the entire risk.

Fixing the reflection but leaving the input unkeyed. Output encoding stops the XSS payload, which is worth doing, but an unkeyed input that still changes the response can enable redirects, resource loading, or denial of service. The durable fix addresses the key itself, so it covers every payload rather than the single one you happened to find.

Forgetting intermediary caches. Marking a response uncacheable at the origin does not help if a CDN or proxy in the path applies its own rules. Every cache in the chain has to agree, so the configuration has to be checked end to end.

Detection signals

Cache poisoning is quiet by nature, but there are indicators worth watching in logs and monitoring.

  • Cached responses containing unexpected hosts or schemes. Links, script sources, or redirects pointing at domains you do not own, served from cache, are a direct sign of a poisoned entry.
  • A spike in identical error responses served from cache for a normal URL. This can indicate cache-key denial of service, where a crafted request made the origin error and the cache stored the error for a legitimate resource.
  • Requests carrying unusual forwarding or custom headers against cacheable paths. Repeated probing with headers like the forwarded-host header on cacheable resources can precede a successful poisoning.
  • Mismatches between what the origin generated and what users report seeing. When users receive content the origin logs do not show it producing for their request, an intermediary cache may be serving a stored, poisoned copy.
  • Discrepancies in cache hit and age headers around a resource. Unusual patterns can reveal that an entry was populated by a request that should not have shaped it.

How the technique became prominent

Caching has always carried the risk that a shared store could serve the wrong content, and the general idea of poisoning a cache is old. What changed is the depth of understanding of how ordinary request inputs feed into cached responses on the modern web.

Two shifts pushed the technique to the front. First, the web moved heavily toward layered caching, with content delivery networks and reverse proxies sitting in front of most large applications for performance. That put a shared cache in the path of nearly every request, which is the precondition the attack needs. Second, research into unkeyed inputs made it clear how many applications reflect headers like the forwarded-host header into links, redirects, and resources, while the caches in front of them ignore those headers when keying. The combination of a universal shared cache and a widespread reflection pattern turned a theoretical risk into a practical, repeatable one.

The framing around the cache key was the conceptual unlock. Once the attack is understood as a disagreement between what shapes the response and what the cache keys on, testing becomes systematic and defense becomes clear. That framing is why the modern treatment of cache poisoning centers on the key rather than on any single vulnerable header, and why the durable fixes all come back to aligning the key with the response.

Frequently asked questions

What is the difference between the cache key and the cache entry? The cache key is the fingerprint the cache computes from selected parts of a request to decide whether it already holds a matching response. The cache entry is the stored response itself. Two requests with the same key are treated as the same and served the same entry, which is why an input left out of the key can let one request's response be served to another.

What is an unkeyed input? Any part of a request that changes the response the origin generates but is not included in the cache key. Forwarding headers, certain custom headers, and query parameters the cache strips are common examples. Unkeyed inputs are the raw material of cache poisoning, because they let two requests the cache considers identical produce different responses.

Does HTTPS stop web cache poisoning? No. Encryption protects the connection, while cache poisoning targets the content a cache is allowed to store and serve. The transport can be fully secure while the cached response is attacker-shaped. The defense is at the cache and application layer, not the transport layer.

How is this different from cross-site scripting? Cross-site scripting is about injecting script into a page. Cache poisoning is a delivery mechanism that can carry an XSS payload, among other effects. A reflected XSS bug normally harms only the user who clicks the crafted link. Routed through a shared cache, the same payload is served to everyone who requests the resource, with no link to click.

What is the single most important defense? Making the cache key include everything that changes the response, or removing those inputs before the origin can react to them. If nothing unkeyed can alter a cached response, the primitive disappears. Stripping untrusted forwarding and custom headers at the edge is often the most direct way to achieve that.

Can a CDN configuration alone cause this? Yes. A CDN that keys only on host and path while the origin varies its response on a header the CDN ignores creates the exact condition poisoning needs, without any bug in the application code. Cache configuration is part of the attack surface and has to be reviewed alongside the application.

How does cache poisoning relate to request smuggling? HTTP request smuggling can force a poisoned entry into a cache even where the cache would not otherwise accept the attacker's input directly. A smuggled request desynchronizes how the front-end and back-end parse the stream, letting the attacker plant a response against a key that legitimate users will hit. The two techniques chain naturally.

Web cache poisoning weaponizes the very layer you added for speed, and it scales a single crafted request to everyone the cache serves. Attackers look for the unkeyed input that the origin still trusts. Our Exploit Intelligence dashboard tracks how these infrastructure-level techniques appear in real exploitation activity. Align your cache keys with everything that shapes the response, strip the headers the origin should never trust, and keep untrusted input out of cached content, and the cache goes back to serving pages instead of payloads.

Sources & further reading

Sharetwitterlinkedin

Related guides