CriticalWeb Cache Deception· 4 min read

The Fake .js File That Cached Everyone's Login

An API that handed out session tokens plus a CDN that thought it was serving JavaScript equals a one-click account takeover you could serve to anyone.

Target anonymized · a national grocery & retail chain

The target was the web storefront of a national grocery & retail chain — the kind of site where millions of people log in, save a delivery address, store a card, and click "buy" without thinking twice. That last part is exactly what makes these programs interesting. A boring bug on a boring endpoint becomes a very loud bug when the endpoint sits behind someone's saved payment method.

I was mapping the app's authenticated surface, watching the requests the SPA fired after login, when one of them stopped me.

The spark

Among the usual chatter to the product and basket APIs, the front end called something like:

GET /api/token-manager/get-tokens?key=AUTH_TOKEN

And it answered — cheerfully, in JSON — with my own session token. Not a cookie the browser managed for me, but the raw token value, handed back in a response body. The app was built on a hosted identity provider that stashed its access and refresh tokens where client-side JavaScript could read them, and this endpoint was the read-back mechanism.

My first instinct was the boring one: is this token even sensitive, or is it a public one? I pasted it into an authenticated request as my AUTH_TOKEN. It sailed straight through. This was the real thing — the key to the account. So an endpoint reflected my session token to me. On its own, that's fine. I already have my own token. The question that mattered was: could I ever make it hand my token to someone else?

Digging in

The obvious path was reflected — could I trick a victim's browser into leaking the response cross-origin? CORS was locked down, no Access-Control-Allow-Origin wildcard, and the token wasn't in a page I could frame. Dead end. XSS would do it, but I didn't have XSS, and "chain it with a bug I don't have" is a fantasy, not a finding.

Then I looked up the stack instead of across it. The whole site sat behind a CDN, and CDNs love to cache anything that looks like a static asset. Which raised the real question: what does this edge cache think a static asset is?

Almost always, the answer is "the URL ends in something like .js or .css." The origin decides what's dynamic; the CDN decides what's cacheable — and they use completely different rules to decide. When those two rules disagree, you get Web Cache Deception.

I needed a URL that the origin would route to get-tokens but the CDN would file away as a harmless script. Enter the path parameter. A semicolon starts a matrix/path parameter segment that most origin routers happily ignore when matching the route — but the CDN sees only a string ending in .js.

The exploit

The primitive was a single crafted URL. The victim, while logged in, loads it (a link in an email, an image tag, anything that fires a same-site GET):

GET /api/token-manager/get-tokens;.js?key=AUTH_TOKEN&cb=unique123 HTTP/2
Host: shop.example.com
Cookie: AUTH_TOKEN=<VICTIM_SESSION_JWT>

HTTP/2 200 OK
Content-Type: application/json
Cache-Control: public, max-age=600      ← applied by the CDN's *.js rule
X-Cache: MISS

{"token":"<VICTIM_SESSION_JWT>"}

The origin ran the handler, read the victim's cookie, and returned the victim's token. The CDN looked at the .js suffix, decided "static," and stored that per-user response in the shared cache. The throwaway cb=unique123 parameter just pins a private cache key both parties share, so the attacker retrieves the exact entry the victim populated instead of fighting live traffic.

Then the attacker — logged out, no cookies, nothing — requests the identical URL:

GET /api/token-manager/get-tokens;.js?key=AUTH_TOKEN&cb=unique123 HTTP/2
Host: shop.example.com

HTTP/2 200 OK
X-Cache: HIT

{"token":"<VICTIM_SESSION_JWT>"}

X-Cache: HIT. The victim's token, served to a stranger. Set it as your own AUTH_TOKEN and you are the victim. Swap key=AUTH_TOKEN for key=REFRESH_TOKEN and you get persistence too — a fresh session long after theirs expires.

The cache and the origin never disagreed about anything except one thing: whether this URL was code or data. That single disagreement was the entire vulnerability.

Impact

Full account takeover across the platform, delivered as a link. Whoever opened it handed over their live session and their refresh token: order history, saved addresses, stored payment methods, loyalty balance — and, with the refresh token, a foothold that outlived their login. No credentials phished, no password reset, no malware. Just a URL and a cache doing exactly what it was told.

The fix

Three layers, any one of which breaks the chain:

  1. Send Cache-Control: no-store on every authenticated response. Tokens are the definition of "never cache."
  2. Cache on Content-Type and explicit directives, not URL suffix. A JSON 200 from an API route should never match a *.js rule, semicolon or not. Normalize or reject path parameters at the edge.
  3. Stop returning raw tokens over HTTP at all. Keep session and refresh tokens in HttpOnly cookies the client can't read back, and this endpoint — the whole class of bug — simply ceases to exist.
web cache deceptionaccount takeovercdnauth tokenspath confusion

All identifiers, targets and payloads in this post are anonymized or defanged. Findings were reported and resolved through responsible disclosure.