Web Cache Deception: When Your CDN Serves One User’s Data to Everyone

Web cache deception is an attack that tricks a shared cache into storing a response meant for one logged-in user, so that anyone requesting the same URL afterwards is handed that user's data. It does not exploit a flaw in the application. It exploits a disagreement between the cache and the application about where the URL ends.

Two components read the same request. One of them decides what to run. The other decides what is worth keeping. Web cache deception is what happens in the gap between those two readings, and neither component has to be broken for it to work.

Key takeaways

  • A cache key is built from the request method and the target URI. Nothing in it identifies the user, so once a private response is stored, it belongs to whoever asks next.
  • The exploit is a path the application and the cache parse differently: the application answers a private route, the cache sees a filename it is configured to keep.
  • Three disagreements produce it: path mapping (the application ignores a trailing segment), delimiters (the framework stops reading at a character the cache passes through), and normalization (each side resolves encoded characters differently).
  • Caching on the file extension in the path is the usual trigger. A path ending in .css is not evidence that a stylesheet came back.
  • Cache-Control: no-store on authenticated responses is the one control that does not depend on two parsers agreeing.

What a cache key does not contain

A cache decides whether it has already seen a request by computing a key. RFC 9111 defines it: "The 'cache key' is the information a cache uses to choose a response and is composed from, at a minimum, the request method and target URI used to retrieve the stored response."

Read that for what is absent. No session. No cookie. No user. That is not an oversight, it is the point: a cache exists to serve one stored answer to many callers, and it can only do that if identity is not part of the lookup. The safety of the whole arrangement rests on a single assumption, that anything placed in the store was safe to hand to anyone.

Web cache deception never breaks the key. It gets a private response into the store under a name a stranger can ask for.

The URL that means two things

Consider a route that returns the signed-in user's account page at /account/settings. An attacker sends the victim a link to /account/settings/x.css.

The application matches the route and ignores the trailing segment, which is ordinary behavior for a framework that treats path suffixes as parameters. It authenticates the session, renders the victim's page, and returns 200. The cache in front of it sees a path ending in .css, applies the rule that static assets are cacheable, and stores the response. The attacker then requests /account/settings/x.css with no cookies at all and is served the victim's page from the cache.

PortSwigger's Web Security Academy describes the underlying mismatch as one of path mapping: origin servers may use traditional file-system style paths or REST-style logical endpoints, and exploitation happens when the cache interprets a trailing segment as a file request while the origin ignores it as insignificant. OWASP files the same problem under WSTG-CONF-13, Test Path Confusion: "if the routes are not configured correctly and the target also uses a CDN, the attacker can use this misconfiguration to execute web cache deception attacks."

Nothing was bypassed. Authentication ran. The session was valid. The response was correct for the person who requested it. The only defect is that two systems disagreed about where the path ended, and one of them wrote the answer down.

The delimiters nobody agreed on

The obvious hardening is to stop ignoring trailing segments, so that /account/settings/x.css returns a 404 and there is nothing worth caching. That closes one door.

Martin Doyhenard's "Gotta cache 'em all", published in August 2024 and presented at DEF CON, catalogues the rest. Frameworks and servers recognize their own path delimiters, and a character that terminates the path for one component is just another character to the next:

  • In Spring, "the semicolon is used as a delimiter to include matrix variables", so /account/settings;x.css can route to /account/settings.
  • In Rails, "the dot character can act as a path delimiter".
  • OpenLiteSpeed uses "the null encoded byte as a classic delimiter to truncate the path".
  • Nginx treats "the encoded newline byte" as a path delimiter.

A cache sitting in front of any of these sees the full string, including the extension after the delimiter, and applies its rule to that. The application stops reading earlier and serves the private route. Strict route matching does not help, because from the application's point of view the route matched exactly.

Normalization supplies a third variant. Given a path such as /hello/..%2fworld, the research notes that "some resolve the path to /world, while others don't normalize it at all." Two parsers, two different resulting paths, one triggering a cache rule and the other reaching private content. Whether a proxy normalizes before or after it evaluates its cache rules changes the answer, and that ordering is a deployment detail most teams have never had reason to look up.

What it has looked like

The technique was published by Omer Gil in February 2017 and presented at Black Hat USA. His white paper documents PayPal as an affected target: more than 40 static file extensions could be used to get pages cached, and cached pages remained retrievable for roughly five hours after first access. The exposure window is worth sitting with. A response written once is served for as long as the entry lives, to every request that guesses the same path.

The pattern did not age out. In March 2023 the same class of issue was reported by Gal Nagli against ChatGPT: appending a cacheable extension to the session endpoint, /api/auth/session/test.css, caused the JSON response to be stored, and that response carried the user's email, name, and access token. The cache in front of the application keyed its decision on the extension in the path rather than on the content type of what came back. It was reported and fixed in production the same day, in about an hour and a half.

Six years apart, two different stacks, and in both cases the application was working exactly as designed.

Where the control belongs

The instinct is to fix the parser mismatch. That is worth doing and it is not sufficient, because it requires two independently configured systems, often owned by two different teams, to keep agreeing forever. The durable controls are the ones that hold even when they disagree.

ApproachWhat it relies onHow it fails
Cache on the file extension in the pathThe path ending describing the responseAn extension is a string an attacker appends. This default produced both cases above.
Cache on the response Content-TypeThe origin labelling responses correctlyBetter, but a private HTML page and a public one are both text/html.
Allowlist of exact cacheable pathsThe cache matching a fixed listFails closed. An invented path is not on the list, so it is not stored.
Cache-Control: private on authenticated responsesShared caches honoring the directiveRFC 9111: a shared cache "MUST NOT store the response". Correct, but the origin has to set it on every such response.
Cache-Control: no-store on authenticated responsesThe sameRFC 9111: a cache "MUST NOT store any part of either the immediate request or the response". The strongest statement the origin can make.
Normalize the path before cache rules are evaluatedBoth parsers resolving identicallyRemoves the normalization class. Leaves the delimiter class untouched.

The ordering that matters: the origin is the only component that knows a response was personalized. The cache never does, and cannot be asked to infer it from a URL. So the authoritative control is a response header the application sets on anything it rendered for a specific user, and every path-level rule is a second layer under it. MITRE's CWE-524 names the underlying weakness plainly: "Use of Cache Containing Sensitive Information", where "the cache can be read by an actor outside of the intended control sphere."

One practical note for anyone testing this. It cannot be found by reading either configuration on its own. The application's routing table looks right, the cache's rules look right, and the defect exists only in the composition of the two. It shows up when a request is sent to the real deployed address, through the real cache, and the response is checked for whether it was stored and what a second unauthenticated request receives.

Nothing was bypassed. The cache stored what it was told was static, and handed it to whoever asked next.

Related readingPart of our series on modern application security testing. Start with What You Should Know About Application Security Testing, then read Reverse Proxy Access Control Testing on the rules that live in front of your code, and CORS Misconfiguration: The Wildcard Is Not the Risk on a second way a shared cache republishes a response to the wrong party.

Frequently asked questions

What is web cache deception?

It is an attack that gets a shared cache to store a response containing one user's private data, under a URL that anyone can then request. The attacker sends the victim a link to a path that the application resolves to a private page but that the cache classifies as a static asset. The victim's authenticated response is stored, and the attacker retrieves it with no session of their own.

How is web cache deception different from web cache poisoning?

They move in opposite directions. Poisoning puts attacker-controlled content into the cache so it is served to other users. Deception puts another user's content into the cache so it is served to the attacker. As PortSwigger frames it, poisoning "manipulates cache keys to inject malicious content into a cached response", while deception exploits cache rules to store sensitive content the attacker then retrieves.

Does authentication or HTTPS prevent it?

No. Authentication runs normally and succeeds, which is what makes the stored response valuable in the first place. Transport encryption protects the response in flight and has no bearing on whether an intermediary you deployed yourself decides to keep a copy. The cache is a trusted part of your own delivery path, not an eavesdropper.

Why does appending .css to a URL cache a private page?

Because many cache configurations decide what to store from the file extension at the end of the path rather than from the content type of the response. The extension is part of the request, so it is entirely attacker-controlled. If the application ignores the trailing segment and returns the private page anyway, the cache stores a personalized HTML response under a name that looks like a stylesheet.

What is the difference between Cache-Control: private and no-store?

Per RFC 9111, private tells a shared cache it "MUST NOT store the response", while permitting a private cache such as the user's own browser to keep it. no-store goes further: a cache "MUST NOT store any part of either the immediate request or the response". Use private for personalized pages that are fine in the user's own browser, and no-store for responses that should never be written down anywhere, such as session and token endpoints.

Which endpoints are most at risk?

Anything that returns data scoped to the caller and is reachable by a predictable URL: account and profile pages, session and token endpoints, order histories, message threads, dashboards, and API routes that answer with whatever the current session is entitled to see. The risk is highest where a path suffix or a delimiter can be appended without changing which route the application selects.

Test what your application actually answers.

Start free, or book a demo to see NightVision derive your API inventory from source and test it fully authenticated against the endpoints your application really serves.