Web Cache Deception: Path Confusion, Delimiters, and Static-Extension Tricks
Web cache deception (WCD) is the inverse of cache poisoning. Instead of corrupting a cached resource that everyone receives, you trick a shared cache into storing a victim's private, authenticated response under a URL you control. Once it is cached, you simply request that URL yourself and read the victim's account page, API key, CSRF token, or session-bound data straight out of the edge cache — no XSS, no token theft, just a discrepancy between how the cache and the origin interpret the same path.
The attack was popularized by Omer Gil in 2017 and has only grown more relevant as CDNs (Cloudflare, Akamai, Fastly, CloudFront) sit in front of nearly every modern application. The core bug is almost never in the cache or the origin alone — it lives in the disagreement between them. This guide covers the four discrepancy classes that make WCD work, how to confirm a finding without harming real users, and how to fix it on both layers. Everything here assumes an authorized engagement against a target you have permission to test.
The Root Cause: Caching Rules vs. Routing Rules
Caches decide whether to store a response using fast, syntactic rules — usually a static file extension (.css, .js, .jpg), a path prefix (/static/, /assets/), or a Cache-Control header. Origin servers and frameworks, by contrast, route requests using their own normalization logic that often ignores trailing segments, collapses slashes, or strips suffixes before matching.
WCD happens when you craft a single URL that the cache classifies as a cacheable static asset while the origin still resolves it to a dynamic, authenticated handler. The origin returns the victim's private page; the cache sees a .css on the end and happily stores it.
Technique 1: Path Confusion (Appended Segments)
The original WCD vector. Many frameworks ignore a path segment appended after a valid route. Request the victim's profile endpoint with a fake static file glued onto the end:
GET /account/settings/nonexistent.css HTTP/1.1
Host: target.com
Cookie: session=<victim-session>
If the origin routes /account/settings/nonexistent.css to the same handler as /account/settings (because it ignores the trailing segment), it returns the authenticated settings page. The cache, however, sees a .css suffix and a 200 response, and caches it. The attacker then fetches the same URL unauthenticated:
# Attacker, no cookies — should be a login redirect, but isn't
curl -s https://target.com/account/settings/nonexistent.css | grep -i "email\|api_key\|csrf"
If you see the victim's data, the cache served the stored private response. Test which appended forms the origin tolerates: /account;foo.css, /account%2f%2e%2e%2fx.css, /account/..%2fsettings.css, and a plain /account/x.js. Different routers behave differently — Express ignores some trailing paths, Spring's path-matching is suffix-sensitive, and PHP apps often resolve PATH_INFO loosely.
Technique 2: Delimiter Discrepancies
This is the more powerful modern variant, detailed in Portswigger research. Caches and origins disagree about which characters terminate a path. Suppose the origin treats ;, ?, or an encoded newline as the end of the meaningful path, while the cache treats it as a literal path character that continues up to the extension.
GET /api/account?;%0aignored.css HTTP/1.1
Host: target.com
Cookie: session=<victim-session>
Tomcat, for example, historically used ; to introduce matrix parameters and would route /api/account;jsessionid=x.css to /api/account. The cache key, meanwhile, includes the whole ...x.css string. Useful delimiters to fuzz, both raw and percent-encoded:
;(matrix parameters / path parameters) — Tomcat, some Java stacks%3f— an encoded?the cache treats as path but the origin may decode as a query separator%23— encoded fragment#, sometimes truncated origin-side%0a/%0d— encoded newline/CR, truncating the origin's view of the path%00— null byte, occasionally truncates in older parsers\(%5c) — backslash treated as a separator by some Windows-based origins
Probe the cache and origin separately. Send /api/account[DELIM]test.css and watch the response: a private 200 with cache headers (cf-cache-status: HIT, x-cache: HIT, an Age header) on a second request is your confirmation.
Technique 3: Static-Extension and Static-Directory Mapping
Beyond appended paths, caches often apply a blanket rule: "cache anything ending in a known static extension" or "cache everything under /static/." If the origin's normalization strips or ignores the extension, you win. Two flavors:
# Extension rule abuse: cache stores by suffix, origin ignores it
GET /profile.css HTTP/1.1 # origin maps to /profile
GET /profile.js?x=1 HTTP/1.1 # query ignored origin-side, cached as static
# Static-directory abuse: traverse back into a dynamic route
GET /static/..%2f..%2faccount HTTP/1.1
Host: target.com
Cookie: session=<victim-session>
In the directory case, the cache sees the /static/ prefix and caches unconditionally, while a path-traversal sequence the origin decodes routes the request back to /account. Always test a wide extension list — caches frequently include .css .js .jpg .png .gif .ico .svg .woff .woff2 .ttf .map .txt .pdf — because the longer the list, the more likely one slips past the origin's matcher unchanged.
Technique 4: Confirming Without Harming Users
You must demonstrate impact while only ever caching your own response. Never deliberately cache another person's live data during testing. The safe methodology uses two accounts you control:
- Account A (victim role): authenticate and request the candidate WCD URL once. Note whether a private response comes back.
- Cache check: immediately re-request the same URL with no cookies (a fresh client). If you receive Account A's private data, the discrepancy is real and cacheable.
- Cache-buster hygiene: append a unique query value (
?wcd=<random>) while mapping behavior so you do not pollute the live cache, then drop it only for the final controlled proof.
# Step 1 (Account A): does the appended path return a private page?
curl -s -b "session=$A_SESSION" \
"https://target.com/account/settings/probe$RANDOM.css" -D-
# Step 2 (no auth): is that private page now served from cache?
curl -s "https://target.com/account/settings/probe$SAME_VALUE.css" -D- \
| grep -iE "cf-cache-status|x-cache|age|set-cookie"
A HIT status plus the victim's content is the whole proof. Inspect response headers carefully — the HTTP Header Analyzer helps you read Cache-Control, Vary, Age, and vendor cache-status headers at a glance, and the curl Command Builder is handy for assembling the exact authenticated and unauthenticated request pairs you need to demonstrate the discrepancy.
Picking High-Value Targets
Not every cached page is interesting. Focus your candidate URLs on endpoints that return per-user secrets in the body:
- Account, profile, and billing pages (PII, partial card data, addresses)
- Anything that embeds an anti-CSRF token in HTML — caching it lets you bypass CSRF defenses for the victim
- API-key, OAuth-client, or settings pages that print credentials
- Password-reset confirmation pages that echo a token
- JSON endpoints returning session-bound data, e.g.
/api/me,/api/account
WCD pairs naturally with other request-parsing bugs. The same delimiter and normalization discrepancies that drive WCD also power HTTP request smuggling, and WCD is the conceptual sibling of web cache poisoning — both exploit the gap between cache key and origin behavior, just in opposite directions.
Defenses and Remediation
Because WCD is a two-system disagreement, durable fixes address both the cache and the origin. Fixing only one layer leaves the discrepancy exploitable as soon as a config drifts.
- Cache by content type, not file extension. Configure the CDN to cache based on the origin's
Content-Typeand explicitCache-Control: public, not on a URL suffix. A response withContent-Type: text/htmlshould never be cached as a static asset regardless of a.cssin the path. - Respect Cache-Control from the origin. Authenticated responses must send
Cache-Control: no-store, privateand the cache must honor it. Many WCD reports trace back to a CDN overriding origin cache directives for "static-looking" paths. - Normalize paths identically on both layers, or reject ambiguity. If the origin ignores trailing segments or matrix parameters, the cache must key on the normalized form too. Better: make the origin return 404/redirect for unexpected suffixes instead of silently routing them to a dynamic handler.
- Strict-match dynamic routes. Disable suffix and trailing-path tolerance in the framework. For Spring, avoid suffix pattern matching; for Tomcat, disable matrix parameters or set them not to alter routing; for Express, avoid wildcard catch-alls that resolve
/account/anythingto the account handler. - Set a strict
Varyand never cache responses withSet-Cookie. A response that issues a session cookie is by definition per-user and must bypass the shared cache. - Cloudflare and similar: use Cache Rules that match on response content type and bypass cache for authenticated cookies, rather than the legacy "cache everything by extension" page rule.
cf-cache-status: BYPASS (or no cache hit) — never the authenticated body.
Web cache deception is deceptively simple to find once you stop thinking of the cache and origin as one system and start hunting for the seams between them. Build a small fuzz list of extensions and delimiters, automate the authenticated-then-anonymous request pair, and watch the cache-status headers. The bugs are out there precisely because almost everyone configures caches and routers independently — and they rarely agree.
Level up your security testing
Install the CLI
npx payload-playgroundExplore All Tools
Encoding, hashing, JWT & more
Browse Cheat Sheets
Quick-reference payload guides