Web cache deception is not cache poisoning, and the distinction matters for both exploitation and defence. In cache poisoning, the attacker writes malicious content into the cache to serve it to other users. In cache deception, the attacker does not write to the cache at all — they trick the victim into making a request that causes the caching layer to store the victim’s private, authenticated response, and then the attacker retrieves it.
The vulnerability is documented since 2017, has affected PayPal, OpenAI, GitLab, and dozens of other major platforms, and continues to surface in bug bounty programmes because the root cause — path parsing inconsistency between web servers and CDNs — is structural, not easily fixed with a single patch.
How It Works
The attack exploits a mismatch between how the web application and the caching layer interpret URL paths.
Setup: A web application serves personalised, authenticated content at /account/profile. The endpoint returns data specific to the authenticated user (name, email, payment methods, API keys, whatever the application exposes at that route). The web application’s caching configuration correctly marks this response Cache-Control: no-store or equivalent — no caching intended.
The mismatch: The CDN or reverse proxy in front of the application uses file extension to decide what to cache. Requests ending in .css, .js, .jpg, .png, .woff are assumed to be static assets and cached regardless of response headers. Requests without these extensions pass through to the origin.
The attack: The attacker constructs a URL like /account/profile/nonexistent.css. The CDN sees .css extension and decides this is a static asset to cache. The web application receives the request, strips or ignores the trailing path segment (because of how its router handles unknown sub-paths), and serves the authenticated /account/profile response. The CDN caches this response, keyed on the full URL including .css.
The attacker then tricks an authenticated victim into requesting /account/profile/nonexistent.css — via a crafted link in a phishing email, an injected resource reference, or any method that causes the victim’s authenticated browser to make the request. The victim’s authenticated response is now stored in the public CDN cache.
The attacker requests /account/profile/nonexistent.css from the same CDN edge node. The CDN returns the cached response — the victim’s private account data — to the unauthenticated attacker.
Why This Still Happens
The root cause is that caching decisions and path routing are made by different systems with different rule sets.
Extension-based cache rules are common because they are operationally convenient — cache .css and .js files globally, serve dynamic pages from origin. This heuristic breaks when application routers are tolerant of extra path segments.
Permissive application routers accept paths with unexpected suffixes because framework defaults often return the same content for /profile and /profile/anything.css, treating the trailing segment as a non-existent sub-resource rather than a distinct route. Rails, Express, Django, and Next.js all have routing behaviours that can contribute to this in specific configurations.
Path normalisation differences between CDN and origin create additional exploitation surface. Some CDNs normalise paths before using them as cache keys (removing duplicate slashes, resolving . and .. segments) while the origin server processes the raw path. Normalisation differences can allow an attacker to create a URL that the CDN treats as a cache-eligible static asset path but the origin treats as an authenticated API route.
Exploitation Patterns
Basic Extension Confusion
The fundamental pattern:
# Authenticated endpoint that should never be cached
https://app.example.com/api/user/settings
# Attacker crafts cache deception URL
https://app.example.com/api/user/settings/style.css
# CDN caches it if:
# 1. CDN caches .css extensions regardless of Cache-Control
# 2. Application router serves /api/user/settings for /api/user/settings/style.css
# 3. Response includes auth-specific data
Path Delimiter Variations
When CDNs and origins normalise paths differently, attackers can exploit the gap:
# Semicolons as path delimiters (common in Java/Spring Matrix Variables)
/account/profile;.css
# Null bytes in some older CDN implementations
/account/profile%00.css
# URL-encoded slashes
/account/profile%2F.css
# CDN decodes to /account/profile/.css (static asset)
# Origin receives %2F literally, maps to /account/profile
Cache Key Manipulation
When CDN cache keys exclude query strings:
# If CDN caches based on path only, ignoring query string:
/account/profile.css?v=1 (cache miss, fetches from origin)
/account/profile.css?v=1 (subsequent request hits cached private response)
Testing for Web Cache Deception
The testing approach is systematic: identify authenticated endpoints that return personalised data, then probe whether adding file extensions causes the CDN to cache the response.
Step 1: Identify cache-deceptable endpoints. Target endpoints that:
- Return user-specific data (profile pages, account settings, API key listings, payment info)
- Are accessed via GET requests
- Return content that would be valuable if exposed (not just user preferences)
Step 2: Add extension suffixes and probe caching behaviour.
import requests
session = requests.Session()
# Authenticate
session.post("https://app.example.com/login", data={"user": "test", "pass": "test"})
# Test base endpoint
base_response = session.get("https://app.example.com/account/profile")
print(base_response.headers.get("x-cache")) # miss? hit?
# Test with extension suffix
ext_response = session.get("https://app.example.com/account/profile/test.css")
print(ext_response.headers.get("x-cache"))
print(ext_response.text[:200]) # Same content as base?
# Verify cache hit with unauthenticated session
anon_session = requests.Session()
anon_response = anon_session.get("https://app.example.com/account/profile/test.css")
print(anon_response.headers.get("x-cache")) # If "HIT" — vulnerable
print(anon_response.text[:200]) # Victim's data visible?
Step 3: Identify CDN cache header indicators.
| CDN | Cache miss header | Cache hit header |
|---|---|---|
| Cloudflare | CF-Cache-Status: MISS | CF-Cache-Status: HIT |
| Fastly | X-Cache: MISS | X-Cache: HIT |
| AWS CloudFront | X-Cache: Miss from cloudfront | X-Cache: Hit from cloudfront |
| Nginx proxy_cache | X-Cache-Status: MISS | X-Cache-Status: HIT |
Step 4: Test a range of extensions and path variations. CDN cache rules vary; one extension may not trigger caching while another does.
/account/profile/test.css
/account/profile/test.js
/account/profile/test.png
/account/profile/test.woff
/account/profile/test.svg
/account/profile/test.ico
/account/profile;test.css (semicolon delimiter)
/account/profile%0A.css (newline)
/account/profile%23.css (fragment hash)
A Real-World Pattern: Next.js Path Routing
Next.js App Router’s default behaviour can contribute to cache deception when combined with CDN extension-based caching. The Next.js router serves a page component for /dashboard and will also handle requests to paths like /dashboard/nonexistent-file by returning the same page (rather than a 404) when no nested route matches. If a CDN caches paths ending in .js and the Next.js application returns the authenticated dashboard for /dashboard/bundle.js, the conditions for cache deception are met.
The specific exploit depends on the CDN configuration and the Next.js routing setup, but the combination of permissive routing and extension-based CDN caching is a pattern that has surfaced in multiple real-world reports.
Mitigations
1. Set Cache-Control Headers Correctly on Every Authenticated Endpoint
The most robust defence is ensuring the origin sends correct cache headers that prevent the CDN from caching the response, regardless of URL structure:
Cache-Control: no-store, private
Vary: Cookie, Authorization
no-store instructs compliant caches not to store the response at all. private marks it as user-specific. For CDNs that honour these headers (Cloudflare, Fastly, and others do by default), this prevents caching even if the URL looks like a static asset.
Verify your CDN’s behaviour with these headers — some older CDN configurations or custom rules may override no-store for extension-matched paths.
2. Use Cache Rules That Check Content-Type, Not Extension
Configure your CDN to make caching decisions based on the response Content-Type header rather than the URL extension:
# Nginx: cache based on content type from upstream response
proxy_cache_valid 200 1d;
# Only cache if content-type is static asset type
# Combine with proxy_no_cache for non-static content-types
proxy_no_cache $http_cookie;
proxy_cache_bypass $http_cookie;
In Cloudflare, use Cache Rules to match on http.response.headers["content-type"] rather than file extension. A response with Content-Type: application/json or Content-Type: text/html should not be cached as a static asset regardless of the request URL.
3. Return 404 for Unknown Sub-Paths on Authenticated Routes
Application routers should return a 404 for any request to a path that doesn’t map to a real route under an authenticated endpoint. If /account/profile is a defined route but /account/profile/anything.css is not, the application should return 404, not the profile page. Most web frameworks support this with explicit routing configuration:
# Flask: strict_slashes and explicit route definitions
@app.route("/account/profile", strict_slashes=False)
def profile():
# Returns profile only for exact path match
pass
// Express: explicit 404 for unknown sub-paths under /account
app.get('/account/profile', profileHandler);
app.use('/account/profile/*', (req, res) => res.status(404).end());
4. Use Cache Keys That Include All Differentiating Request Attributes
Configure your CDN to include cookies, the Authorization header, or other authentication identifiers in the cache key. A response cached for an authenticated user should never be returned for an unauthenticated request:
# Cloudflare Cache Rule (Cache Key configuration)
Cache Key: Include Cookie: session_id, auth_token
# Or: vary by Authorization header
This does not prevent the deception attempt, but it ensures that even if the CDN caches an authenticated response, it will not serve it to a requester with a different (or absent) authentication token.
5. Audit CDN Cache Rules for Extension-Based Catch-Alls
Review every cache rule that matches on file extension. For each extension rule, ask: is it possible for an application route to serve dynamic, authenticated content at a URL that ends in this extension? If the answer is yes (or unknown), add a content-type check or a path prefix exclusion to scope the rule correctly.
Summary
Web cache deception is exploited through the gap between how caching infrastructure decides what to store and how application routers decide what to serve. The fix requires coordination across both layers: correct Cache-Control headers from the origin, content-type-based cache rules in the CDN, and strict path handling in the application. Any one of these alone is insufficient if the others are misconfigured.
The vulnerability has a straightforward testing methodology and a clearly defined remediation path. The cost of a mature attacker finding a cache deception flaw before your team does is high — account takeover at scale with no server-side vulnerability required.