Your CDN cached a response with a key in it. Now everyone gets the key.
A CDN does exactly one job. It takes a response your server produced, stores it, and hands the same bytes to the next person who asks for that URL, so your server doesn't have to answer twice. It stores whatever you mark as cacheable, and it has no idea what's inside the response. It doesn't read it. It files the bytes and serves the bytes.
So picture a response that has a secret in its body, a config endpoint that accidentally includes a server API key, an HTML page with a token baked into an inline script, a JSON blob with a credential that shouldn't have rendered. On its own, that's one bad response. Now add a Cache-Control: public header to it. The CDN files those exact bytes, secret included, and from that moment the secret isn't leaking to one user. It's published to every visitor who hits that URL, served straight from the edge, fast and identical every time.
This is a close cousin of web cache deception, and worth telling apart from it. That attack is about a shared cache storing one user's logged-in page and handing it to the next visitor. This is simpler and, in a way, worse: it's a secret in a response that's marked shareable, so there's no per-user session involved and no trick required. The CDN just does its job on a response it should never have been allowed to cache.
Two headers on one response
The whole problem is the combination of two things on the same response: a real secret in the body, and a directive that says any shared cache may store and serve it.
"Shared-cacheable" means one of two signals. Either the response carries Cache-Control: public with a non-zero max-age, which is you explicitly telling every cache in the path to keep this and hand it out, or a CDN reports it already did, with a cf-cache-status: HIT or x-cache: HIT header showing the bytes came from the edge cache rather than your origin. Either way the response is sitting in a store that strangers read from.
The body half of the check isn't "this looks like it might be a secret." It runs the same engine the rest of the scanner uses to find real secrets in JavaScript, with the same filters: public-by-design keys like a Stripe pk_live_ or a Google AIza... browser key are ignored, placeholders and example values are dropped, and only a genuine server-only credential counts. So the finding means an actual secret is in the body of an actually-cacheable response, not a guess on either side.
Why a secret ends up in a cacheable response
The two halves usually arrive from different directions and meet by accident. The secret gets into the body through the ordinary leak paths: a server-rendered config object that picked up a server-only variable, an endpoint that serializes too much, a key inlined into a template. And the caching gets turned on as a blanket performance setting, a Cache-Control: public applied to a whole route prefix or static-feeling path for speed, by someone who never imagined a dynamic response under it would carry a credential.
Neither person did something obviously wrong in isolation. The endpoint author thought "this is just config." The caching author thought "this path is just assets." The collision is what publishes the key, and because the CDN serves it fast and silently, nothing alerts. You find out the way people usually find out about a leaked key: from the bill, or from someone who already used it.
The fix is to keep secrets out of cacheable responses
The right rule is simple to state and worth enforcing as a rule rather than a one-off cleanup: any response carrying sensitive data must be marked uncacheable by a shared cache. Cache-Control: private lets the user's own browser cache it but forbids every CDN and proxy. no-store says don't keep it anywhere. Either is the correct answer for a response with a credential in it, and a response carrying one of those is excluded from this check, not flagged.
HTTP/1.1 200 OK
Cache-Control: public, max-age=3600
{ "serverKey": "sk_live_..." } <-- now in the shared cacheBut fixing the header is only half the cleanup, and not the important half. If the secret was ever served from cache, it's compromised, so rotate it. Then take it out of the response body entirely, because a server-only key has no reason to be in something the client receives, cacheable or not. The header change stops the next visitor from getting it; the rotation deals with everyone who already did.
This is readable from outside because it's all in the response: the headers and the body together, no login required, which is the same view a CDN has and the same one SurfaceCheckr takes. We fetch your pages, check whether the response is marked shareable by a CDN, and run the real secrets engine over the body, and we only raise it when both are true at once, a genuine secret in a genuinely cacheable response. We don't replay the request through your CDN or probe your cache, and we're not a pentest. We read what the edge would store and tell you if it's a credential, which is the one combination that turns a cache into a publisher.
Read next
- Your "Sign in with Google" button builds a public URL. What's in it?HTTPS, TLS, and the headers that protect your visitors
- Your TLS cert is valid, but is it leaking hostnames, signed with a weak key, or living too long?HTTPS, TLS, and the headers that protect your visitors
- Is your CDN storing one user's logged-in page for the next visitor?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.