Blog/Web Security
Web SecurityJuly 30, 2026 · 7 min read

Web Cache Poisoning: Serving Your Payload to Everyone

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:

  1. The attacker requests the homepage with X-Forwarded-Host: evil.com.
  2. The app builds a <script src="https://evil.com/app.js"> from that header and returns the page.
  3. The CDN caches this page under the normal homepage key (the header wasn't part of the key).
  4. 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

// VULNERABLE — building URLs from an attacker-controlled, unkeyed header:
//   const base = 'https://' + req.headers['x-forwarded-host'];
//   res.send(renderPage({ scriptBase: base }));

// SAFE — never trust host-ish headers for content. Use a configured,
// trusted host, so no unkeyed input can reach the cached HTML.
const CANONICAL_HOST = 'https://app.example.com';

app.get('/', (req, res) => {
const html = renderPage({ scriptBase: CANONICAL_HOST });  // fixed, not reflected
res.set('Cache-Control', 'public, max-age=300');
res.send(html);
});

// If a response legitimately varies by an input, either add that input
// to the cache key at the CDN, or announce it with Vary so caches
// don't share entries across different values:
//   res.set('Vary', 'Accept-Language');

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-Host and 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 Vary honestly 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.

Share this post

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 read

Mass 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 read

Insecure 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