120 —PHP
Cache-Control leak: the missing word that cached sessions
At 16:47 a customer screenshotted someone else's order history in his own dashboard. The bug was a Cache-Control header on the PHP account page, missing one word.
At 16:47 on a Thursday, the founder of a Dutch agency we work with forwarded a screenshot from a furious customer: the customer's account dashboard showed someone else's name, someone else's order history, someone else's saved address. He had refreshed. Still the wrong account. He had opened a private window and logged in fresh. Wrong account again. The agency's helpdesk had four more tickets like it stacked up in the last twenty minutes.
By the time we got on the call, the support engineer had already pulled the obvious lever: purge the Cloudflare cache. Tickets stopped within ninety seconds. The leak had run for about an hour and a half on a custom-PHP shop that handles around 2,000 logged-in sessions a day. The root cause, by 18:10, turned out to be a single missing word in a Cache-Control header on the account page.
What the edge actually saw
The account dashboard was served by a handler that looked roughly like this:
// /account/index.php
session_start();
if (empty($_SESSION['user_id'])) {
header('Location: /login');
exit;
}
header('Content-Type: text/html; charset=utf-8');
header('Cache-Control: max-age=300');
render_account($_SESSION['user_id']);
Two years ago a junior dev had added that max-age=300 line to take pressure off the database after a Black Friday spike. It worked, in the sense that subsequent loads of the dashboard for the same user inside five minutes skipped PHP entirely. The page was fast. Nobody complained. The line stayed.
What that line did not say was private. Per MDN's Cache-Control reference, a response without private or no-store is fair game for any shared cache between origin and browser. Cloudflare, sitting between PHP and the user, is a shared cache. It read max-age=300, saw no opposing directive, and treated the rendered HTML as a static asset to be served to the next visitor who hit the same URL.
The next visitor got the previous visitor's name. And, in some response paths where the framework's session rotation middleware reset cookies, the next visitor also got the previous visitor's PHPSESSID in a cached Set-Cookie header. That is the part the founder did not sleep over that night.
The actual fix, in three lines
The patch was deliberately small. We changed one header, added a second, and shipped:
header('Cache-Control: private, no-store, max-age=0');
header('Pragma: no-cache');
header_remove('Expires');
For the same-origin shared edge, private alone would have stopped the leak. We layered no-store as belt-and-braces because the shop also runs a Varnish layer for static pages, and we did not want to litigate Varnish's interpretation of every directive in a hurry. The Pragma line is for older intermediaries that some of the customer's corporate clients still operate behind. Useless for Cloudflare, harmless everywhere else.
Then we walked the rest of the codebase looking for the same mistake. We found four more handlers with max-age lines that should have been private: the order detail page, the saved-addresses endpoint, the wishlist API, and one of the checkout intermediate steps. None of them had leaked yet, by the access logs, but they were one Cloudflare page rule change away from doing so.
The audit pattern that catches this
Once you have seen one of these, you start seeing them everywhere. The pattern to grep for in any PHP project older than three years:
grep -rEn "Cache-Control:.*max-age" --include="*.php" .
grep -rEn "header\(.*Cache-Control" --include="*.php" .
For each hit, the question is: does this URL ever return content that depends on the session? If yes, the directive needs private or no-store. The OWASP session management guidance is blunt about it: any response that includes session-derived content must declare itself private to shared caches.
The same audit should cover .htaccess, where these headers are sometimes set globally:
# Bad: applies to /account too unless explicitly excluded
<FilesMatch "\.(php|html)$">
Header set Cache-Control "max-age=600"
</FilesMatch>
# Better: only the things you actually want cached
<FilesMatch "\.(css|js|woff2|png|jpg|svg)$">
Header set Cache-Control "public, max-age=2592000, immutable"
</FilesMatch>
A blanket Header set in .htaccess is the bigger version of the same bug. It hits every dynamic PHP response under that prefix, including the ones with Set-Cookie headers attached. We have seen this in two Magento 1 codebases and one elderly Drupal 7 site in the last six months alone.
Why the bug stays hidden so long
The reason this class of bug runs for months before it manifests is that the conditions for triggering it are narrow. The shared cache only serves a stale response when:
- Two users request the same URL within the
max-agewindow. - The first response actually entered the edge cache (which depends on the response status, size, and the CDN's own eligibility rules).
- The second user's request key matches: same path, same vary headers, same edge node.
For most account-page URLs (/account, /dashboard), the URL is identical for every logged-in user. So once eligibility is met, the leak is one cache hit away. The triggering event at the Dutch agency was a Cloudflare config change earlier that afternoon: a developer had enabled "Cache Everything" on a page rule that, due to a typo in the path pattern, ended up matching /account/*. The bad header had been sitting there for two years waiting for that page rule.
The lesson is not "audit your Cache-Control headers when you change CDN config". The lesson is that the Cache-Control header on a session-bearing response should be defensively wrong-proof, because the CDN configuration above it will change over its lifetime and you will not be in the room when it does.
The small thing to do today
Pick the oldest account page handler in your codebase. Run the grep above. Read the headers you find with the question, "what happens if Cloudflare or Varnish or a corporate proxy decides to treat this as cacheable?" If the answer is "the next user sees the previous user", change the line.
When we built Pier we ran into this exact class of problem on a customer's legacy site: the way we ended up handling it was to make every header change land in version history, so the moment a leak starts you can diff today's response headers against last Wednesday's and see the one word that moved. The same versioning runs over the MySQL editor, which is what mattered the next morning when we had to confirm no sessions had actually been hijacked.
If you want one concrete next step before you close this tab: open your account page handler, find the Cache-Control line, and confirm the word private is in it. That is the smallest version of the audit, and it catches the version of this bug that hurts the most.
— Questions —
Is setting Cache-Control: private enough to stop a CDN from caching session-bound pages?
Yes for compliant shared caches, but layer no-store too. Some intermediaries interpret directives loosely, and Cloudflare page rules can override origin headers entirely on certain plan tiers.
What is the difference between no-cache and no-store on a PHP response?
no-cache allows storage but requires revalidation on every request. no-store forbids storage in any cache, anywhere. For pages that depend on session state, no-store is the safer choice.
Can I cache logged-in pages at all without leaking sessions?
Yes, with per-user cache keys via Vary on a session-identifying header, or edge-side includes for the dynamic fragments. Most teams find the operational cost outweighs the database savings.
How do I detect this bug before a customer reports it?
Log every response that includes Set-Cookie alongside its Cache-Control header. Any row where the latter lacks private or no-store is a leak waiting for a CDN config change above it.