A shared cache, whether a CDN, a reverse proxy such as nginx or Varnish, or a framework's output cache, answers repeat requests without asking the origin. It decides on its own whether a response may be stored and which later requests may receive it. Web cache attacks live in the gap between those decisions and what the origin actually did.
There are two classes. Web cache deception tricks the cache into storing a signed-in user's private page under a static-looking URL that anyone can fetch afterward. Web cache poisoning sends an input that the application reflects but the cache leaves out of its key, so one attacker's value is replayed to everyone requesting that URL. SelfSec reports the first as CWE-524, use of a cache containing sensitive information, and the second as CWE-349, acceptance of extraneous untrusted data with trusted data, both at high severity.
The mental model: two parsers, one URL
The cache key is the information a cache uses to choose a stored response. RFC 9111 says it is composed, at a minimum, of the request method and target URI. Many caches also decide what to store from the URL alone, with rules such as "cache everything ending in .css or .js". The origin resolves the same URL through its own router, which may read it quite differently.
| Request URL |
What an extension rule concludes |
What a lenient origin returns |
/account |
Dynamic page, do not store |
The account page |
/account.css |
Stylesheet, store it |
The account page, when .css is read as a format suffix |
/account/ssecpoc.css |
Stylesheet, store it |
The account page, when the route ignores trailing segments |
/account;ssecpoc.css |
Stylesheet, since ; is an ordinary character |
The account page, when ; starts a path parameter |
Every row below the first is a private response with a public-looking name. Poisoning is the same disagreement applied to headers instead of paths: the origin reads X-Forwarded-Host, and the cache key does not.
How it happens
Deception needs two decisions to line up. First, the edge caches by extension and overrides the origin's headers:
location ~* \.(css|js)$ {
proxy_pass http://app_backend;
proxy_cache static;
proxy_ignore_headers Cache-Control Set-Cookie;
proxy_cache_valid 200 1h;
}
Second, the origin is lenient about what it treats as /account. Java servlet containers remove ;name path parameters before mapping a request, which is how ;jsessionid= works. Spring MVC before 5.3 matched /account.css to the /account handler through suffix pattern matching, and a Django re_path(r'^account') without an end anchor matches account.css. Combined with the edge rule, the account page becomes a stylesheet.
Poisoning needs an unkeyed input that changes the response. An application that builds absolute URLs from X-Forwarded-Host writes the header's value into links or script imports while the cache keys only on method and URL. The Host header cache poisoning post walks through that sink in detail.
A concrete test: prove storage, not reflection
An authorized tester needs a test account, a genuinely private page, and a cache-buster: a unique query parameter that keeps every probe on a cache entry no real user will request. First, fetch /account signed in and anonymously, and pick a distinctive string, such as an account reference, that appears only in the signed-in copy. Then request a confused URL with the test session:
GET /account/ssecpoc.css?ssec_cb=k3v9q2m7x1d4 HTTP/1.1
Host: shop.example
Cookie: session=<test-account-session>
If the origin returns the account page, send the identical URL with no cookie:
GET /account/ssecpoc.css?ssec_cb=k3v9q2m7x1d4 HTTP/1.1
Host: shop.example
HTTP/1.1 200 OK
Content-Type: text/html; charset=utf-8
Age: 3
X-Cache: HIT
The anonymous response contains the signed-in marker and carries a cache hit. That combination is the proof: the private bytes came from the cache, not from a fresh render. Without a hit indicator the marker is only a lead, since some caches do not announce hits, and an origin that renders private data for a request with no session has an access control problem rather than a caching one.
Poisoning is tested the same way, with a unique hostname under a reserved domain that can never resolve:
GET /?ssec_cb=p8w2n5c0r6t1 HTTP/1.1
Host: shop.example
X-Forwarded-Host: ssec4k9w2p7d1x0a.cachetest.invalid
If the hostname comes back in the body or a response header, repeat the request without X-Forwarded-Host. A reflection alone affects only the tester; the finding is the clean request still carrying the hostname with a cache-hit indicator.
The cache did exactly what its rules said. It simply understood the URL differently from the application behind it.
What an attacker gains
Deception turns one click into a data leak. The attacker sends a signed-in victim a link to /account/anything.css; a top-level navigation carries SameSite=Lax cookies, so the victim's page is stored, and the attacker fetches it anonymously:
- Personal data from profile, order and billing pages.
- Tokens embedded in the page, such as anti-forgery tokens that unlock cross-site request forgery, or API keys shown in account settings.
Poisoning reaches everyone who shares the cache key:
- Script injection at scale through a poisoned script import or attribute, delivered to every visitor as stored XSS.
- Redirect and link hijacking, including reset and login links built from the poisoned host.
- Denial of service, when an unkeyed header provokes an error page that gets cached.
A cache can also be poisoned without any unkeyed header when two servers frame requests differently; see HTTP request smuggling.
The fix: let the origin decide, and make the cache listen
The durable fix has three parts. The origin marks every dynamic response Cache-Control: no-store, private unless it is deliberately public, the cache honors that header and stores only content designed to be static, and every input that shapes a response is either keyed or removed before it reaches the application.
In ASP.NET Core, make no-store the default after static files, so a public page must opt in with its own Cache-Control:
app.UseStaticFiles();
app.Use(async (context, next) =>
{
context.Response.Headers.CacheControl = "no-store, private";
await next(context);
});
app.UseAuthentication();
app.UseAuthorization();
Keep AllowedHosts in appsettings.json set to your real hostnames, and leave ForwardedHeaders.XForwardedHost out of the forwarded headers middleware unless a proxy you control sets it and ForwardedHeadersOptions.AllowedHosts restricts it.
In Django, never_cache adds Cache-Control: max-age=0, no-cache, no-store, must-revalidate, private; outermost, it also covers the login redirect:
from django.contrib.auth.decorators import login_required
from django.shortcuts import render
from django.views.decorators.cache import never_cache
@never_cache
@login_required
def account(request):
return render(request, "account.html")
Route it with path("account", account), which matches exactly, and keep ALLOWED_HOSTS explicit with USE_X_FORWARDED_HOST = False so request.get_host() and build_absolute_uri() never take a client's forwarding header.
At the edge, cache a directory you own rather than an extension, honor the origin's headers, and drop client-settable forwarding headers:
proxy_cache_path /var/cache/nginx/assets keys_zone=assets:10m max_size=1g;
upstream app_backend {
server 10.0.0.11:8080;
}
server {
listen 443 ssl;
server_name shop.example;
ssl_certificate /etc/nginx/tls/shop.example.crt;
ssl_certificate_key /etc/nginx/tls/shop.example.key;
proxy_set_header Host $host;
proxy_set_header X-Forwarded-Host "";
proxy_set_header X-Forwarded-Server "";
proxy_set_header X-Host "";
proxy_set_header X-Host-Override "";
proxy_set_header Forwarded "";
location /assets/ {
proxy_pass http://app_backend;
proxy_cache assets;
proxy_cache_key $scheme$host$request_uri;
proxy_cache_valid 200 10m;
}
location / {
proxy_pass http://app_backend;
}
}
Only /assets/ is ever stored, so /account;ssecpoc.css falls through to the uncached location. No proxy_ignore_headers appears, so the origin's no-store or private, or a Set-Cookie, still prevents storage; that veto matters because decoding and dot-segment differences can steer a private route into a cached directory. On a CDN, do not let an edge TTL override origin headers on application routes, and enable protections that compare the URL's extension with the response Content-Type, such as Cloudflare's Cache Deception Armor.
Fixes that do not hold
- Setting
no-store at the origin while the edge overrides it: "cache everything" rules and ignored headers discard the origin's decision.
- Blocking
.css after private paths: ;, encoded slashes, other extensions and dot segments produce the same confusion.
- Relying on
Vary: Cookie: support for Vary differs between caches, and some ignore it.
- Short TTLs: an attacker can fetch the stored copy within seconds.
- Validating
Host but honoring X-Forwarded-Host: the unvalidated header still reaches the URL builder.
A developer review checklist
- List every cache between client and origin, including CDN rules, reverse proxies and framework output caches.
- Default dynamic responses to
Cache-Control: no-store, private and opt public pages in.
- Confirm no edge rule caches by extension or overrides origin headers on application routes.
- Request private routes with
.css, /x.css and ;x.css appended; each should return 404, not the page.
- Strip
X-Forwarded-Host, X-Forwarded-Server, X-Host, X-Host-Override and Forwarded at the edge, or accept them only from known proxies.
- Build absolute URLs from a configured canonical origin.
- Test with a unique cache-buster and marker, and accept only a cache-hit response as proof.
References