← Blog
Networking & ISP Systems

Keep private pages out of browser cache without mistaking logout for erasure

Apply modern cache directives at the correct boundary, revoke server-side sessions, test browser history and intermediaries, and understand where bfcache changes the threat model.

By Emmanuel Corels

A user signs out, presses Back, and sees the account page again. That can be a stored HTTP response, a browser back/forward snapshot, or a page that rendered cached API data. It does not automatically prove the server session still works—but it is a privacy failure on a shared device, and the possibility of a live session must be tested separately.

Separate four controls that are often collapsed into “no cache”

ControlQuestion it answers
Session revocationWill the server reject the old credential?
HTTP cache policyMay a browser or intermediary store/reuse the response?
History snapshot policyCan browser navigation restore an in-memory document?
Client data lifecycleDoes JavaScript, a service worker, or local storage retain private data?

No single header answers all four.

Use current response directives, not a folklore bundle

Cache-Control: no-store, private
Content-Type: text/html; charset=utf-8

RFC 9111 defines no-store as a direction not to store any part of the response. private prevents a shared cache from storing the response, while allowing a private cache unless another directive forbids it. Combining them makes the audience explicit and is useful defense in depth.

no-cache does not mean “do not store.” It allows storage but requires successful validation before reuse. Use it when a representation may be retained and efficiently revalidated; do not substitute it for no-store on highly sensitive pages.

Cache-Control: private, no-cache
ETag: "account-view-42-v17"

Correct the accepted answer’s historical compatibility advice

The accepted answer recommends Pragma: no-cache and Expires: 0 for old HTTP/1.0 clients. RFC 9111 now describes Pragma as a historical request compatibility mechanism and does not define it as a response replacement for Cache-Control. Modern applications should make Cache-Control authoritative. Add legacy fields only when measured compatibility requirements justify them; they do not strengthen session security.

Set policy on every sensitive response

Apply the header at the application or narrowly scoped reverse-proxy location so HTML, JSON, error responses, and redirects carrying private data share the policy:

// PHP, before any output
header('Cache-Control: no-store, private');
header('Content-Type: text/html; charset=utf-8');

Do not add a site-wide no-store rule blindly. It destroys caching for public assets and increases latency, bandwidth, and origin load. Conversely, a header only on the HTML shell is insufficient if /api/account returns the actual private data.

Make logout a server-side state change

POST /logout HTTP/1.1
Cookie: session=opaque-id
Origin: https://app.example.test

On receipt, revoke or rotate the server-side session, clear the cookie with matching attributes, protect the request against CSRF as appropriate, and redirect with a non-sensitive response. A logout that only deletes a browser cookie leaves copied or replayed credentials live.

Set-Cookie: session=; Max-Age=0; Path=/; Secure; HttpOnly; SameSite=Lax
Cache-Control: no-store

The Path and, if present originally, Domain must match the cookie being removed.

Diagnose the path with evidence

  1. Capture the sensitive response in browser developer tools with “Disable cache” off.
  2. Inspect the final response after CDN and proxy processing, not only application logs.
  3. Sign out, then replay the old authenticated request outside the browser. It must fail.
  4. Navigate Back and determine whether a network request occurs.
  5. Inspect service workers, Cache Storage, IndexedDB, local storage, and in-memory state.
  6. Repeat on a shared-cache path and with a second account to test cross-user isolation.
curl -i https://app.example.test/account \
  -H 'Cookie: session=OLD_SESSION'

curl -i https://app.example.test/api/account \
  -H 'Cookie: session=OLD_SESSION'

After logout, both should return an unauthenticated response and no private body. Redact credentials before preserving traces.

Understand the back/forward cache boundary

Modern browsers can restore an in-memory page snapshot without performing an HTTP cache lookup. Browser behavior evolves, and cache-control interaction is not uniform across all navigation scenarios. Treat this as an additional layer: listen for pageshow, revalidate authentication when a page is restored, and replace sensitive UI before showing data.

window.addEventListener("pageshow", async (event) => {
  if (!event.persisted) return;
  const response = await fetch("/api/session", {
    credentials: "same-origin",
    cache: "no-store"
  });
  if (!response.ok) location.replace("/login");
});

This is defense in depth, not the primary security boundary. An attacker can disable JavaScript; the API must reject the revoked session.

Clear client-owned private data deliberately

await caches.delete("private-api-v1");
sessionStorage.removeItem("account-view");
history.replaceState(null, "", "/signed-out");

Prefer never storing secrets in Web Storage. A service worker must not cache authenticated responses under a key that omits user identity, authorization state, or relevant Vary dimensions. Purging on logout reduces residue but cannot repair a cache that already leaked data between users; rotate credentials and investigate scope.

Verification matrix

  • Fresh authenticated navigation returns the intended private data and no-store.
  • Refresh and Back after logout reveal no private content.
  • The old cookie fails at every private HTML and API route.
  • A second user never receives the first user’s representation through CDN or proxy caching.
  • Offline mode does not reveal a service-worker-cached private response.
  • Error pages and redirects do not embed account data or cacheable tokens.

Failure modes and production trade-offs

no-store increases repeat transfers and prevents useful offline behavior. For moderately sensitive dashboards, private, no-cache plus validators may be acceptable; for health, finance, administrative, or identity data, the privacy benefit of no-store usually outweighs the cost. Measure origin capacity before rollout.

Do not use “clear site data on every logout” as an unreviewed hammer: it can remove unrelated origin data, disrupt multi-account workflows, and behave differently across clients. Do not append random query strings to private pages; cache busting changes cache keys but does not forbid storage.

Rollback and incident recovery

If the new policy overloads the origin, retain session revocation and private-cache isolation while rolling back incrementally to a reviewed revalidation policy. Never restore shared caching for user-specific responses as an emergency performance fix. If cross-user exposure occurred, purge affected CDN keys, revoke sessions, preserve cache configuration and logs, determine which identities and fields were exposed, and follow the incident-notification process.

Logout is proven by rejection at the server. Privacy is improved by cache policy. Browser history is tested as its own execution path.

Sources: the Stack Overflow question by Edward Wilde and accepted answer by BalusC (CC BY-SA), RFC 9111: HTTP Caching, and Chrome’s current cache-control and back/forward cache guidance.

Primary source: Review the official reference ↗