Is your CDN storing one user's logged-in page for the next visitor?
You turned on a CDN for speed. A CDN does exactly one thing: it stores a response so it can hand the same bytes to the next person who asks for that URL, skipping your server entirely. It stores whatever you tell it is public, and it has no idea who any individual visitor is. So when your logged-in homepage goes out with a Cache-Control: public header on it, the CDN files it away and serves it to whoever comes next.
That page had a session in it. Maybe the user's email rendered in the corner, maybe a Set-Cookie line that re-issued their session token, maybe an API key baked into an inline script. The shared cache doesn't read any of that. It saw a cacheable response, so it cached a cacheable response, and now it's a per-user page sitting in a store that every visitor reads from.
Two headers on one response are the whole problem: a session Set-Cookie and a shared-cache directive. Those two should never coexist, and when they do, the cache is a few requests away from leaking one account into another.
How web cache deception turns this into a takeover
The plain version is bad enough. An authenticated response gets stored in a shared cache, and the next visitor to hit that URL is served the cached copy, session cookie and all. They are now, as far as your app can tell, the previous user.
Web cache deception (WCD) is the targeted version, and it's how an attacker makes the leak happen on demand instead of waiting. CDNs often decide what to cache by file extension or path shape. Anything ending in .css, .js, .jpg is "a static asset, cache it forever," and that rule runs before your app ever sees the request. The trick: a lot of routers treat /account/profile.css as /account/profile. The trailing .css means nothing to your app, which happily renders the dynamic account page, but it means everything to the CDN, which sees a stylesheet and caches the response.
So the attacker sends a logged-in victim a link to /account/profile.css. The victim's browser follows it with their session cookie attached, your app renders their real account page, and the CDN stores that page under the .css URL because it looks like an asset. Then the attacker requests the exact same /account/profile.css from their own machine, with no cookie at all, and the CDN serves them the cached copy: the victim's profile, their email, whatever the page rendered, possibly their session. No password, no XSS, no break-in. Just a shared cache doing precisely what it was told.
The check here catches the precondition that makes all of that possible, which is upstream of the extension trick: a response that carries a session and is also marked shared-cacheable. Close that and the rest of the attack has nothing to stand on.
The header signal
This is readable from outside without logging in, because it's just response headers. The dangerous combination is a session-shaped Set-Cookie together with a shared-cache directive on the same response.
Shared-cacheable means one of two things. Either Cache-Control: public with a non-zero max-age, which is you telling every cache in the path "store and share this," or a CDN reporting it already did, with a cf-cache-status: HIT or x-cache: HIT header showing the response came from cache rather than your origin. Either way the response is being kept in a store that other people read from.
The flip side matters just as much: Cache-Control: private or no-store is the correct setting for an authenticated response, and a response carrying those is excluded, not flagged. private tells the browser it may cache for that one user but no shared cache may. no-store says don't keep it anywhere. Both are right answers, so they don't fire.
The fix
Authenticated responses must never be shared-cacheable. If a response carries a session or any per-user content, send Cache-Control: private, no-store and let nothing in the path store it for anyone else. Most frameworks set this automatically the moment a session is touched, but a stray middleware, a default public rule on a CDN page rule, or a hand-set caching header can quietly override it, which is the case worth checking.
Set-Cookie: session=...; HttpOnly Cache-Control: public, max-age=600
Then fix the rule that let WCD work at all: cache by the full path plus a sane cache key, not by file extension. Don't let /account/profile.css resolve to a dynamic page, and don't tell the CDN to cache by suffix when the suffix is a lie the router ignores. If your CDN supports it, exclude any path that can set a session cookie from the asset-caching rule outright.
SurfaceCheckr reads these response headers from outside, the way any visitor would. No login, no cache-busting probe, no attack traffic. It flags only the dangerous pairing, a session or auth cookie on a response that is also shared-cacheable, and it leaves private and no-store responses alone because those are correct. This isn't a pentest and it won't confirm a live leak; it tells you the precondition is present, which for a high is the part you want to know before someone else finds it.
While you're in the cookie and caching layer, it pairs with getting the session cookie flags right, since the cookie that leaks here is the same one those flags protect, and with a CORS policy that isn't handing credentialed responses to any origin. Run the curl -sI line against your own homepage and read the headers. A session cookie sitting next to public caching is one request away from the wrong person's account.
Read next
- Are your session cookies actually protected? (HttpOnly, Secure, SameSite)HTTPS, TLS, and the headers that protect your visitors
- Your page is HTTPS, but is everything on it? Mixed content, explainedHTTPS, TLS, and the headers that protect your visitors
- Your CDN cached a response with a key in it. Now everyone gets the key.HTTPS, TLS, and the headers that protect your visitors
- Does your site still answer over plain HTTP?HTTPS, TLS, and the headers that protect your visitors
Find it before someone else does.
Paste your domain. The grade and issue count are free, and you'll see in a couple of minutes exactly what's reachable from outside.