Cache poisoning injects content for other users to receive. Cache deception exfiltrates what was already returned to one user - by tricking the cache into storing a private response as if it were a public asset.
Cache poisoning injects malicious content into a shared response so other users receive it. Cache deception runs the opposite way: the attacker tricks the cache into storing a private response belonging to a victim, then retrieves it cold from a session that holds no credentials. The confirming artifact is a cold cache hit against a URL no legitimate user would ever visit - not a reflected payload.
The trigger is a path suffix the cache treats as a static file extension but the application ignores during routing. Request /account/profile/nonce.css and the application strips the trailing segment - or matches only the first path component - and hands back the authenticated user's profile data. The cache sees .css, classifies the resource as a public static asset, applies a long TTL, and stores the response keyed on that URL with no session credential in the cache key. Any unauthenticated request to the same URL now receives the stored private response. No injection required; the application returned the data voluntarily.
Confirming deception safely means never using a URL a real user could organically visit. The probe uses a benign nonce suffix - /account/settings/62615533.css - so the stored cache entry is scoped to a key no legitimate session ever requests. The probe has two legs: first, send the nonce URL authenticated as a controlled test account; second, request the same URL from a fresh unauthenticated session. A response carrying the controlled account's private data in the second request, with X-Cache: HIT or a non-zero Age header, is the confirming artifact. No real user's data is requested or stored at any point.
# leg 1: authenticated request populates the cache entry GET /account/settings/62615533.css HTTP/1.1 Cookie: session=controlled-test-account-token HTTP/1.1 200 OK X-Cache: MISS # cache miss - first request, not yet stored Content-Type: text/html Cache-Control: max-age=3600 # app did not set no-store # leg 2: unauthenticated request retrieves the stored private response GET /account/settings/62615533.css HTTP/1.1 # no Cookie header HTTP/1.1 200 OK X-Cache: HIT # cache served the stored entry Age: 8 {"email": "test-account@example.local", "name": "Controlled Test User"} # private data - unauthenticated
Every caching layer applies its own ruleset for which extensions get cached, how the cache key is constructed, and which response headers force a bypass. Cloudflare caches a defined list of static extensions by default and respects Cache-Control: no-store to opt out. Nginx proxy_cache does not cache 200 responses unless proxy_cache_valid is configured. Varnish's default VCL caches everything unless a Set-Cookie or Authorization header appears in the response - a subtle rule that means a page returning session data without Set-Cookie may be cached when it should not be. AWS CloudFront caches on path-pattern behaviors defined in the distribution; an overly broad pattern covering *.css is enough to trigger deception on any endpoint whose URL ends with a matching suffix. The confirming header differs by layer and must be read correctly or the probe looks like a miss when it is not.
| Cache layer | Default extension caching | Override to prevent caching | Confirming HIT signal |
|---|---|---|---|
| Cloudflare | Static extension list (css, js, png, ...) | Cache-Control: no-store | CF-Cache-Status: HIT |
| Nginx proxy_cache | Off unless proxy_cache_valid configured | proxy_no_cache directive or no-store header | X-Cache-Status: HIT |
| Varnish (default VCL) | Caches 200 unless Set-Cookie or Authorization in response | Set-Cookie in response, or Vary: Cookie | X-Cache: HIT or Age > 0 |
| AWS CloudFront | Configurable per path-pattern behavior | Cache-Control: no-store forwarded to origin | X-Cache: Hit from cloudfront |
A settings page returning the account email address and display name is high - private data is reachable unauthenticated. An endpoint returning a session token, an API key, a payment method identifier, or bulk user PII is critical - direct account takeover or regulated-data exposure is confirmed. A cacheable page carrying no personal data is reachable but may be low. Rate at the worst field the cold-cache response actually carries, not at the vulnerability class. The nonce-URL design means the probe only ever stores data belonging to the controlled test account, so severity is assessed from what that account's page contains - no real user is involved in the rating decision.
The fix is layered because both the application and the cache contributed to the gap. Application-side: set Cache-Control: no-store on every response from an authenticated handler - unconditionally, not guarded by a content-type check. Do not rely on the cache layer to infer privacy from a path pattern. Cache-side: configure the CDN or reverse proxy to require explicit opt-in caching rather than defaulting to cache-by-extension. When the cache layer cannot be changed, adding Vary: Cookie forces a separate cache entry per credential context, preventing the deceptive entry from being served unauthenticated. The application fix is the more robust of the two and is independent of caching-layer behavior.
Cache deception is the mirror image of cache poisoning: one writes malicious content into the shared cache for other users to receive; the other reads private content out of it from a session that was never authenticated. The confirming probe is safe by design - a nonce URL scopes the stored entry to a key no real user reaches - and the oracle is unambiguous: a cold cache hit carrying private data is not an inference, it is the response itself. That combination is what makes the finding defensible in a review: the evidence is the HTTP exchange, not a category guess.