AppSec · Jul 17, 2026 · 7 min read

Web cache deception: storing what the cache should not

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.

01A routing mismatch the cache exploits

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.

Step 1 victim (authenticated) is lured to: GET /account/profile/nonce.css ──► app ignores .css, returns private profile data (Cache-Control: max-age=3600 - no-store omitted) ──► cache sees .css extension ──► stores response publicly, TTL 1 h Step 2 attacker (no cookie, new session): GET /account/profile/nonce.css ──► X-Cache: HIT Age: 12 body = victim private data
Application routes on the real path; cache routes on the extension. That gap is the entire attack surface.

02The confirming artifact: a cold cache hit on a nonce URL

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.

cache-deception-probe.httphttp
# 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
Proven - HighWeb cache deception - authenticated data exposed - /account/settings/
Leg 1 GET /account/settings/62615533.css Cookie: session=controlled-test ──► 200 X-Cache:MISS Cache-Control:max-age=3600 Leg 2 GET /account/settings/62615533.css (no cookie, fresh session) ──► 200 X-Cache:HIT Age:8 body=controlled test account data
Oracle: cold-cache hit on a nonce URL scoped to a controlled test account. No real user data requested or stored. Severity high - unauthenticated access to cached authenticated data. Escalates to critical on endpoints returning credentials, payment details or bulk PII.

03CDN and reverse-proxy behavior changes the probe, not the oracle

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 layerDefault extension cachingOverride to prevent cachingConfirming HIT signal
CloudflareStatic extension list (css, js, png, ...)Cache-Control: no-storeCF-Cache-Status: HIT
Nginx proxy_cacheOff unless proxy_cache_valid configuredproxy_no_cache directive or no-store headerX-Cache-Status: HIT
Varnish (default VCL)Caches 200 unless Set-Cookie or Authorization in responseSet-Cookie in response, or Vary: CookieX-Cache: HIT or Age > 0
AWS CloudFrontConfigurable per path-pattern behaviorCache-Control: no-store forwarded to originX-Cache: Hit from cloudfront

04Severity follows what the confirmed response contains

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.

2
probe legs, zero real user data accessed
4
cache layers with distinct HIT headers
1
header (no-store) closes the application-side gap

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.

Evidence over heuristicsReachable beats foundMonitor → Block: rolling out a gate without slowing teamsIaC-grounded threat modelingAir-gapped AppSec with no phone-homeOne platform vs three tools
See it on your own app →