Web cache poisoning: how it works, how testers prove it, how to fix it

Web cache poisoning makes a shared cache store a harmful response and serve it to other users. It works because some request inputs, often headers such as X-Forwarded-Host, change the response but are not part of the cache key. Fix it by caching only static content, removing unused headers, and adding needed inputs to the key.

Web cache poisoning is an attack in which someone abuses the behavior of a web server and its cache so that a harmful HTTP response is served to other users. PortSwigger describes two phases: get the back end to return a response that carries a payload, then make sure the cache stores it. It is not DNS cache poisoning, which attacks resolvers and has different defenses.

Only test systems you own or have written authorization to test. Every example here uses a fictional app on example.com.

What is web cache poisoning?#

A shared cache (a CDN, a reverse proxy or an application cache) saves a response and sends the saved copy to later visitors. To decide whether two requests are the same, the cache compares a small set of request parts called the cache key. PortSwigger says this is usually the request line and the Host header. Everything else is "unkeyed".

The bug appears when an unkeyed part changes the response. The attacker sends one request with a bad value in that part. The server builds a response that contains it, the cache stores the response under the normal key, and the next visitor to that URL gets the poisoned copy. The attacker does not need to reach the victim.

Caching also turns small bugs into serious ones. Kettle's 2018 research notes that header-based XSS is normally harmless, because a victim's browser never sends the attacker's header. A cache removes that barrier.

How the attack works: two requests and a cache buster#

The attacker finds an unkeyed input that the application reflects, then sends a request that gets the reflected response cached. A tester repeats the same two steps with a harmless value and a cache buster, so no real visitor can receive the response.

Demonstration only: Harbor Notes, a fictional app

This is a demonstration against a fictional app. No real customer system was tested. The app runs at https://notes.example.com behind a CDN. The home page builds an absolute image URL for social previews from X-Forwarded-Host, and the CDN ignores that header when it builds the cache key.

Request 1, from the tester. The cb parameter is a unique cache buster:

GET /?cb=7f3a91 HTTP/1.1
Host: notes.example.com
X-Forwarded-Host: canary.example.net

HTTP/1.1 200 OK Cache-Control: public, max-age=300 X-Cache: MISS

<meta property="og:image" content="https://canary.example.net/preview.png">

Request 2, from a second client with no special header, same URL:

GET /?cb=7f3a91 HTTP/1.1
Host: notes.example.com

HTTP/1.1 200 OK Age: 12 X-Cache: HIT

<meta property="og:image" content="https://canary.example.net/preview.png">

The second client never sent the header, yet received the canary host. The cache served the stored copy. The Age and X-Cache headers are examples: each cache names them differently.

In a real attack the reflected value would sit in a script import or a redirect. The tester stops at the harmless canary. The response header values that help here are Age, Cache-Control and any vendor hit or miss header, because they show how long the entry lives and whether the second request came from the cache.

The main variants#

The table groups the cases testers meet most. Each row names the unkeyed input and what the attacker gets.

Variant Unkeyed input Typical result Source
Header reflection X-Forwarded-Host and similar headers Cached XSS, script import hijack, redirect PortSwigger Academy; Kettle 2018
Path override headers X-Original-URL, X-Rewrite-URL One URL served another URL's content CVE-2018-14773
Cache key normalization mismatch Dot-segments, encodings and delimiters the cache and origin read differently Poisoning of a popular resource such as /home Doyhenard, 2024
Fat GET A request body or parameter the origin reads on a GET Cached response built from the body PortSwigger Academy
Cacheable error Any input that makes the origin return an error Cache-poisoned denial of service (CPDoS) CVE-2025-49826
Cached header from the framework Crafted request that makes the framework add caching headers A page meant to stay uncached is stored CVE-2024-46982

Kettle's 2018 post reports that the Vary header can let an attacker target one browser type, because the cache then keys on that header. MDN explains Vary as the way to make a cache store separate responses per listed header.

Web cache poisoning vs web cache deception#

Poisoning and deception both abuse a cache, and they work in opposite directions. PortSwigger puts it this way: poisoning manipulates the cache key to inject malicious content into a cached response, and deception exploits cache rules to trick the cache into storing private content.

Web cache poisoning Web cache deception
Goal Serve an attacker-shaped response to other users Read another user's private response
Who sends the first request The attacker The victim, lured to a crafted URL
What the attacker needs An unkeyed input that changes the response A path the origin and the cache read differently
Example URL Normal URL plus a malicious header /user/123/profile/wcd.css (PortSwigger's example)
Main fix Key or remove the input; cache only static content Cache-Control: no-store, private on dynamic pages; align path parsing

In the deception example, the origin maps /user/123/profile/wcd.css to the profile endpoint and ignores the file name, while the cache sees a CSS file and stores it. The tests in this page for cache behavior also apply to deception, with the roles reversed.

Real incidents and CVEs#

Poisoning has been found in popular frameworks and in large sites. Each entry is from a primary source checked on 9 November 2026.

Case Date What the source says
Kettle, "Practical web cache poisoning" 9 Aug 2018 Found header-based poisoning on sites including Red Hat, Unity and Mozilla's Firefox recipe service. Mozilla patched in under 24 hours. Released the Param Miner extension.
CVE-2018-14773 (Symfony HttpFoundation) 3 Aug 2018 Support for the IIS X-Original-URL and X-Rewrite-URL headers let anyone who could send the header override the request path. Fixed by dropping support. Kettle links this to Drupal and Zend fixes of 1 August 2018.
Doyhenard, "Gotta cache 'em all" 8 Aug 2024 Showed that parser differences and key normalization let an attacker poison resources such as /home. Azure and Imperva normalize cache keys by default.
CVE-2024-46982 (Next.js) 17 Sep 2024 In the pages router, a crafted request could make Next.js cache a route meant to stay uncached. Affected 13.5.1 to 14.2.9. Fixed in 13.5.7 and 14.2.10.
CVE-2025-49826 (Next.js) 3 Jul 2025 A 204 response could be cached for static pages and served to all users. Affected 15.0.4-canary.51 to before 15.1.8. Fixed in 15.1.8. NVD says Vercel-hosted customers were not affected.

Two of these are framework features that trusted headers or built caching headers on their own. A site owner who never wrote a bad line could still be exposed.

How penetration testers detect and prove it#

The method follows PortSwigger's attack steps: find unkeyed inputs, make the server reflect or use one, then check that the cache stores the result. We add controls and a read-back, as in our other tests.

  1. Map the cache. Send a request twice and compare Age, Cache-Control and any hit or miss header. Record which paths are cached and for how long.
  2. Add a unique cache buster to every test URL, as PortSwigger recommends, so test responses cannot reach real visitors. Change it for each new test.
  3. Look for unkeyed inputs. Add random values to headers and compare responses. The Param Miner extension's "Guess headers" feature automates this and can add a buster automatically.
  4. Use a canary value you own, not a script. A host such as canary.example.net on a domain the authorization covers is enough to show reflection.
  5. Run the two-request test. Send the header once, then request the same buster URL from a second client that sends no header. If the canary appears, the cache stored it.
  6. Run controls. Send the second request with a different buster and confirm it is clean. Send the header to an uncached path and confirm no stored copy. Test one header at a time.
  7. Show the impact in a real browser where it matters. Load the poisoned buster URL in Chromium and record what the page does with the canary.

The finding records the buster, both requests with timestamps, the cache headers, and the browser observation. A reflected header alone is a lead. A stored copy served to a second client is the finding. Evidence is labeled "Observed" for the requests and "Browser-observed" for the page behavior. If the cache stored the copy only for the same client, that is a limit, and the report says so.

Safety limits come first. Do not poison a URL without a buster, do not use a live payload, and do not test shared caches in production unless the scope says so. If the cache has no purge option, ask the owner how a stored entry will be removed before you test.

How to prevent web cache poisoning#

Four rules cover most cases. They match PortSwigger's guidance.

  1. Cache only static responses, and check what counts as static. Mark dynamic and personalized responses Cache-Control: no-store, private.
  2. Remove or ignore request headers the application does not need. If the site works without X-Forwarded-Host, drop it at the edge.
  3. If an input must change the response, add it to the cache key, or use Vary for a header. Do not drop parts of the key for speed. Rewrite the request instead.
  4. Reject fat GET requests, and fix reflected values even when they look unexploitable. Cache behavior can make them exploitable.

nginx. The default key is close to $scheme$proxy_host$uri$is_args$args, which does not include request headers. If a header changes the response, put it in the key, and skip the cache for requests that carry credentials.

proxy_cache_key "$scheme$host$request_uri";
proxy_no_cache $http_authorization $cookie_session;
proxy_cache_bypass $http_authorization $cookie_session;
# Remove a header the app does not need
proxy_set_header X-Forwarded-Host "";

proxy_no_cache stops a response from being written to the cache, and proxy_cache_bypass stops one from being read. Use both.

Cloudflare. The documented default cache key includes the scheme, host and URI with query string, plus the Origin header and several forwarding headers, among them x-forwarded-host, x-host, x-original-url and x-rewrite-url. Custom cache keys that add headers, cookies or the host are Enterprise features. Cloudflare also lists Cache Deception Armor as available on all plans.

CloudFront. A cache policy sets the headers, cookies and query strings in the cache key. AWS notes that fewer values in the key raise the hit rate, so add only what changes the response, then check that the origin does not read anything else.

Node.js (Express). Set cache headers on dynamic routes, and do not build absolute URLs from a client header.

app.use((req, res, next) => {
  res.set("Cache-Control", "no-store, private"); // default for dynamic routes
  next();
});
const SITE = "https://notes.example.com"; // fixed, not from X-Forwarded-Host
res.send(`<meta property="og:image" content="${SITE}/preview.png">`);

Java (Spring). Spring's CacheControl builder sets the header for a response.

return ResponseEntity.ok()
    .cacheControl(CacheControl.noStore())
    .body(page);

Python (Flask). Set the header in an after-request hook for dynamic routes, and read the site URL from configuration.

@app.after_request
def no_store(resp):
    if request.endpoint not in STATIC_ENDPOINTS:
        resp.headers["Cache-Control"] = "no-store, private"
    return resp

Upgrade frameworks that had cache fixes. For the Next.js CVEs above, NVD lists fixed releases and says there is no official workaround for CVE-2024-46982. Doyhenard's defense for key confusion is to avoid normalizing the cache key and to avoid forwarding a delimiter suffix to the origin.

Testing checklist#

  • Is each cache identified (CDN, proxy, framework), with its cache key documented?
  • Do all test requests carry a unique buster?
  • Were X-Forwarded-Host, X-Forwarded-Scheme, X-Original-URL and method override headers tested one at a time?
  • Did a second client confirm that a stored copy was served?
  • Did controls with a new buster stay clean?
  • Were error responses (400, 404, 204, 5xx) checked for caching, to find CPDoS?
  • Were path delimiter and encoding differences between cache and origin checked?
  • Do dynamic and personalized pages send no-store, private?
  • Are poisoned entries removed after testing, and is that recorded?

When to get a professional test#

Cache poisoning depends on how your CDN, proxy and application work together, so a scanner that only reads the application misses it. A tester needs written scope that names each cache layer and says who can purge entries. Excession Labs runs authorized tests of this kind as part of a web application penetration test, with the evidence standard described above. The related host header injection page covers the shared mechanism, and the HTTP request smuggling page covers a neighbor that also attacks the path between cache and origin.

Questions

Is web cache poisoning the same as DNS cache poisoning?

No. DNS cache poisoning plants false records in a DNS resolver. Web cache poisoning plants a harmful HTTP response in a CDN, reverse proxy or application cache. The defenses differ: DNSSEC and resolver hardening for DNS, cache keys and header handling for web caches.

What is an unkeyed input?

An unkeyed input is a part of a request that the cache ignores when it decides whether two requests match, but that the application still uses to build the response. PortSwigger says any web cache poisoning attack relies on manipulating unkeyed inputs, such as headers.

What is CPDoS?

CPDoS is cache-poisoned denial of service. The attacker gets an error page or an empty response cached for a normal URL, so every visitor receives the error. CVE-2025-49826 in Next.js is an example: a 204 response could be cached for static pages and served to all users.

How do I test for web cache poisoning without hurting real users?

Add a unique cache buster, such as a random query parameter, to every test request so only your own requests share that cache entry. Use a harmless canary value in the header. Confirm the effect by requesting the same buster URL from a second client without the header.

Does a short cache lifetime stop the attack?

No. PortSwigger notes that short lifetimes offer little protection because an attacker can script repeated re-poisoning. Fix the unkeyed input or stop caching the response.

Sources

  1. PortSwigger Web Security Academy: Web cache poisoning (checked 9 November 2026)
  2. PortSwigger Web Security Academy: Web cache deception (checked 9 November 2026)
  3. PortSwigger Research: Practical web cache poisoning (James Kettle, 9 August 2018) (checked 9 November 2026)
  4. PortSwigger Research: Gotta cache 'em all (Martin Doyhenard, 8 August 2024) (checked 9 November 2026)
  5. Cloudflare Docs: Cache keys (checked 9 November 2026)
  6. Amazon CloudFront Developer Guide: Control the cache key with a policy (checked 9 November 2026)
  7. nginx documentation: ngx_http_proxy_module (checked 9 November 2026)
  8. MDN: Vary header (checked 9 November 2026)
  9. NVD: CVE-2018-14773 (Symfony HttpFoundation) (checked 9 November 2026)
  10. NVD: CVE-2024-46982 (Next.js) (checked 9 November 2026)
  11. NVD: CVE-2025-49826 (Next.js) (checked 9 November 2026)