How an Unanchored Cloudflare Cache Rule Leaked Billing Data Across Accounts for 6 Days
← Back
September 1, 2026Security9 min read

How an Unanchored Cloudflare Cache Rule Leaked Billing Data Across Accounts for 6 Days

Published September 1, 20269 min read

10:58 UTC. A support ticket comes in, subject line "wrong name on my billing page." The customer attached a screenshot: their account settings page, their own logged-in session, showing a name that wasn't theirs. Twelve minutes later, a second ticket. Different customer, same shape of complaint. Whatever this was, it wasn't one confused user.


the scramble

First theory: a session bug. Something in the auth layer was handing out the wrong JWT, or a cookie was leaking across accounts on a shared device. On-call pulled the auth service logs for both customers' sessions. Every token issuance was scoped correctly, right user ID, right expiry, no overlap. Whatever was going wrong wasn't happening at the identity layer.

Second theory: a client-side state bug. React Query or some other cache holding onto a previous user's response after a logout/login cycle on a shared browser. Reasonable guess, easy to test. On-call reproduced the billing page in a fresh incognito window, no prior session, no shared browser state, logged in as a clean test account. The wrong name showed up anyway, on the first load. That ruled out the client. Whatever was serving stale account data, it wasn't happening in the browser.

Third theory, and the one that turned out to matter, came from someone who'd been staring at the response headers instead of the response body.

curl -sD - https://app.example.com/api/plans/me
HTTP/2 200
cf-cache-status: HIT
cf-ray: 8a3f2e1c9d7b0022-SJC
cache-control: public, s-maxage=300, stale-while-revalidate=60
content-type: application/json

cf-cache-status: HIT on a request to a personalized, authenticated endpoint. That header doesn't lie. Something was serving this response from Cloudflare's edge cache instead of hitting origin, and a cached response has no idea which user is asking for it.


the hunt

To confirm it wasn't a fluke, on-call ran the same request through two different authenticated sessions against origin directly, bypassing Cloudflare with --resolve pointed at the origin IP.

bypassing the edge, two sessions
curl --resolve app.example.com:443:10.0.4.12 -H "Authorization: Bearer ${TOKEN_A}" \
  https://app.example.com/api/plans/me
# {"name":"Maria O.","plan":"team","cardLast4":"4471"}

curl --resolve app.example.com:443:10.0.4.12 -H "Authorization: Bearer ${TOKEN_B}" \
  https://app.example.com/api/plans/me
# {"name":"Devon R.","plan":"pro","cardLast4":"9902"}

Origin was correct, every time, for both sessions. Then the same two requests through Cloudflare, no --resolve override, hitting the real edge:

through the edge, same two sessions, inside a five-minute window
curl -H "Authorization: Bearer ${TOKEN_A}" https://app.example.com/api/plans/me
# {"name":"Maria O.","plan":"team","cardLast4":"4471"}   (MISS, populated the cache)

curl -H "Authorization: Bearer ${TOKEN_B}" https://app.example.com/api/plans/me
# {"name":"Maria O.","plan":"team","cardLast4":"4471"}   (HIT, wrong user entirely)

Confirmed. Whichever session hit a given edge PoP first got cached, and every other session landing on that same PoP within the TTL got served the first user's response, Authorization header ignored entirely for cache purposes.

Cloudflare's dashboard showed why. Six days earlier, ahead of a pricing page relaunch, the team had added a Cache Rule to cut origin load on the public plans catalog:

cloudflare cache rule, added 6 days prior
Rule name: "cache public plans catalog"
When incoming requests match:
  URI Path starts with "/api/plans"
Then:
  Cache eligibility: Eligible for cache
  Edge TTL: 5 minutes
  Cache key: default (scheme + host + path + query string, no cookie)

/api/plans was supposed to mean the public catalog endpoint, GET /api/plans, no auth required, safe to cache for anyone. But the personalized "your current plan" endpoint had been built at /api/plans/me, one path segment under the same prefix. A "starts with" match has no concept of route boundaries. It matched both.


the find

Root cause: a Cloudflare Cache Rule scoped by URL prefix matched both a public catalog endpoint and an unrelated personalized endpoint that happened to share the same path prefix, and Cloudflare's default cache key includes scheme, host, path, and query string only, never cookies or the Authorization header. Once /api/plans/me became cache-eligible, the edge had no signal that two requests to that URL could belong to two different people. It cached the first response it saw per PoP and handed it to everyone else who asked within the five-minute TTL, authenticated or not, correct session or not. The bug wasn't in the application. Origin returned the right data on every single request. It lived entirely in a cache configuration that was never told this path needed per-user scoping, because nobody writing that rule knew a private route existed one directory below the public one it was meant to cover.


the fix

First, the immediate stop-the-bleeding move: purge the cache for the affected path and disable the rule while a real fix went out.

emergency mitigation, 11:31 UTC
curl -X POST "https://api.cloudflare.com/client/v4/zones/${ZONE_ID}/purge_cache" \
  -H "Authorization: Bearer ${CF_API_TOKEN}" \
  -H "Content-Type: application/json" \
  --data '{"files":["https://app.example.com/api/plans/me"]}'

Second, the actual fix wasn't to make the cache key smarter, it was to stop letting a prefix match decide what's cacheable at all. We renamed the personalized endpoint so it could never collide with the public one, and moved from a pattern match to an explicit allowlist.

routes, after
// public, safe to cache
app.get('/api/plans', getPlansCatalog);

// personalized, moved out from under /api/plans entirely
app.get('/api/account/plan', requireAuth, getCurrentUserPlan);

const CACHEABLE_ROUTES = new Set(['/api/plans']); // explicit allowlist, no prefix matching

app.use((req, res, next) => {
    if (req.method === 'GET' && CACHEABLE_ROUTES.has(req.path)) {
        res.set('Cache-Control', 'public, s-maxage=300, stale-while-revalidate=60');
    } else {
        res.set('Cache-Control', 'private, no-store');
    }
    next();
});

Every authenticated route now sets private, no-store by default, and only routes on the explicit allowlist can override it. A new route added under the wrong prefix can no longer inherit cache eligibility by accident, because inheriting it isn't possible anymore.

Third, defense in depth at the edge itself, not just the origin. The replacement Cloudflare rule now excludes any request carrying a session cookie, regardless of what the origin's headers say:

cloudflare cache rule, after
Rule name: "cache public plans catalog only"
When incoming requests match:
  URI Path equals "/api/plans"
  AND Cookie does not contain "session_id"
Then:
  Cache eligibility: Eligible for cache
  Edge TTL: 5 minutes

Fourth, a CI check that hits every GET route twice with two distinct authenticated sessions and fails the build if the response bodies match or if a route outside the allowlist ever emits a public Cache-Control header.


the aftermath

6 days The rule was live before the first support ticket
31 Confirmed cross-account exposures traced through cache logs
33 min From first ticket to cache purge and rule rollback
0 Origin requests that ever returned the wrong user's data

Cross-referencing Cloudflare's edge logs against session IDs found 31 confirmed cases where a request's cached response belonged to a different account than the one asking for it, spread across roughly 40 edge PoPs over six days. Most requests to the endpoint never collided with someone else's cache window, which is exactly why it took six days and two support tickets to notice instead of an immediate flood. A low-traffic personalized endpoint sitting behind a five-minute TTL only leaks when two different users happen to land on the same PoP inside that window, which is rare enough to look like nothing is wrong right up until it isn't.

  • A CDN cache rule scoped by "starts with" on a URL path will match anything under that prefix, including routes nobody intended to include. Prefix matches need either an exact match or a hard boundary, not an assumption that the prefix is the whole story.
  • Cloudflare's default cache key is scheme, host, path, and query string. It does not include cookies or the Authorization header unless explicitly configured to. Any personalized response that becomes cache-eligible by accident is public the moment it's cached, no matter how private the origin thinks it is.
  • Origin headers are not the only line of defense. The edge should independently refuse to cache anything carrying a session cookie, so an application-side mistake and a CDN-side mistake both have to happen for data to leak, not just one.
  • A route's path should never imply a security boundary that the routing layer doesn't enforce. Personalized and public endpoints sharing a prefix is a naming convenience that turns into a liability the moment any infrastructure layer, cache, proxy, WAF rule, makes decisions based on that prefix.

The pricing page relaunch the rule was written for shipped fine, on schedule, on a completely different endpoint than the one that caused the incident. Nobody touched that part. The fix was narrower than the damage: one rule, scoped correctly this time, and a routing convention that no longer lets a personalized endpoint borrow a public one's cache eligibility just by living one directory below it.

Share this
← All Posts9 min read