Notes · Dissecting Real Systems

growing

The Header That Can't Be Cached

Cache-Control from first principles — and why a page carrying a CSP nonce must be told never to be stored, not merely "don't cache."

· · 14 min read

security, csp, http, caching, web, dissecting-systems

There are only two hard things in Computer Science: cache invalidation and naming things.

— Phil Karlton (attributed)

Cite this
APA
Mangalapilly, Y. J. (2026, July). The Header That Can't Be Cached. Saṃhitā Notes. https://yesudeep.com/blog/the-header-that-cant-be-cached/
BibTeX
@online{mangalapilly2026the,
          author  = {Yesudeep Jose Mangalapilly},
          title   = {The Header That Can't Be Cached},
          journal = {Sa\d{m}hit\=a Notes},
          year    = {2026},
          month   = {July},
          url     = {https://yesudeep.com/blog/the-header-that-cant-be-cached/},
          urldate = {2026-07-03},
        }
Plain
Yesudeep Jose Mangalapilly. “The Header That Can't Be Cached.” Saṃhitā Notes, 2026. https://yesudeep.com/blog/the-header-that-cant-be-cached/.
RIS
TY  - ELEC
        AU  - Mangalapilly, Yesudeep Jose
        TI  - The Header That Can't Be Cached
        T2  - Saṃhitā Notes
        PY  - 2026
        UR  - https://yesudeep.com/blog/the-header-that-cant-be-cached/
        Y2  - 2026-07-03
        ER  - 

What Cache-Control actually says, and the one place it collides with security. By the end you'll know what each directive really means (one of them does not mean what its name says), the difference between the cache in your browser and the cache in a CDN, and why a page that carries a CSP nonce needs no-store — not no-cache, not private — for a reason most people get subtly wrong.

In the strict-CSP piece I claimed, almost in passing, that a page carrying a nonce "can't be statically cached the way most sites want to cache it." That sentence hides a genuinely tricky bit of HTTP — one I've watched careful engineers get wrong — so it deserves its own page. To see why, you first have to know what Cache-Control really does, because the header is widely used and rarely understood.

Cache-Control is how a response tells every cache between the origin and the browser whether it may be stored, and if so, for how long, and whether it must be rechecked before reuse. Those are three separate questions, and the directives that answer them are easy to confuse.

Two kinds of cache

The first thing to get straight is who is doing the caching, because the header speaks to two audiences at once. RFC 9111, the HTTP caching standard, draws a sharp line:

  • A private cache is "dedicated to a single user" — almost always the cache inside the browser. What it stores, only you see.
  • A shared cache "stores responses for reuse by more than one user" — a CDN, a reverse proxy, a corporate gateway. What it stores, everyone behind it sees.

Think of a response traveling from your server to a reader as a letter passing down a line of people. Some of them keep photocopies to answer the next person faster. The Cache-Control header is a note clipped to the letter telling each one: may you photocopy this at all? for how long is the copy good? and must you call back to check it's still current before handing it on? A shared cache is a clerk copying for a whole crowd; a private cache is the reader's own desk drawer. Some instructions are addressed to the crowd-clerk only, some to everyone.

The request path. A response passes through shared caches (CDN, proxy) before reaching the browser's private cache. Some Cache-Control directives address shared caches only; no-store addresses every cache on the path.

That distinction is the key to half the directives, because several of them target only the shared caches.

The directives, and what they actually mean

Here is the working set, with the meaning the standard gives — not the meaning the name suggests.

max-age and s-maxage — how long a copy stays fresh

max-age=N says the response may be reused for N seconds before it's stale. The clock starts when the response was generated at the origin, not when a cache received it. s-maxage=N is the same idea but for shared caches only, and it overrides max-age for them — so you can let a CDN hold a copy for an hour (s-maxage=3600) while telling browsers to keep it only a minute (max-age=60).

no-cache — the one that doesn't mean what it says

This is the trap at the center of the whole topic.

no-cache does not mean "don't cache." It means "you may store this, but you must revalidate with the origin before you reuse it."

A response marked no-cache can be stored by any cache. What it cannot be is served again without checking first. The check is a conditional request (more on that below), and crucially, that check can come back "nothing changed, reuse your copy." The thing the cache reuses is the copy it already stored. Hold that thought — it's the entire reason the nonce case is hard.

The directive that actually forbids storage is no-store. The names are a historical accident everyone now has to memorize around. MDN puts it bluntly: if the sense of "don't cache" you want is "don't store," no-store is the directive to use. Learn more.

no-store — the only one that forbids keeping a copy

no-store is the strong one. Per RFC 9111, a cache "MUST NOT store any part of either the immediate request or the response." No copy is kept by any HTTP cache — shared or private. Every request goes all the way to the origin and gets a genuinely fresh response. Two things sit outside that guarantee, and both matter to this article's audience. A service worker's Cache API is not an HTTP cache: per MDN, "the caching API doesn't honor HTTP caching headers" — cache.put() will happily store a no-store response and re-serve its stale nonce until your own code deletes it. And the back/forward cache is not a cache of responses at all but a frozen snapshot of the live page (more on it below). This is the directive that matters for nonces, and we'll come back to why it and not its softer cousins.

private and public — which audience may store it

private tells shared caches to keep their hands off: "a shared cache MUST NOT store the response." But — and this is the part people miss — it explicitly permits the browser to store it: "a private cache MAY store the response." So private relocates the copy to the reader's own machine; it does not stop the copy from existing.

public is mostly the inverse escape hatch — it lets a shared cache store something it otherwise couldn't (a response to an authenticated request, for instance). You rarely need it: absent explicit freshness information, caches may compute a lifetime themselves — heuristic caching, RFC 9111 §4.2.2, with its suggested "no more than some fraction" (typically 10%) of the time since Last-Modified — so ordinary cacheable responses don't need public at all. public is for overriding a prohibition, not for granting permission you already have.

must-revalidate and immutable — the two extremes of staleness

must-revalidate says: once this is stale, you may not serve it again until you've successfully rechecked with the origin (no serving a stale copy just because the origin is slow or down). immutable is the opposite promise, from RFC 8246: "this will not change while it's fresh, so don't even bother revalidating on a reload." It's what you put on a content-hashed asset (app.9f3c.js) you intend to serve public, max-age=31536000, immutable for a year. (This site does exactly that — the colophon describes the fingerprinting.)

The three independent questions a policy answers, and which directive governs each. no-store is the only one that answers "may you keep a copy?" with no.

The conditional request, and the 304 that ruins everything

To understand why nonces need no-store specifically, you need one more mechanism: how a cache revalidates.

When a response carries a validator — an ETag (an opaque version fingerprint) or a Last-Modified date — a cache can ask the origin a conditional question: "I have version W/"abc"; is it still good?" (If-None-Match: W/"abc"). The origin can answer one of two ways:

  • 200 OK with a full new body — here's a newer version, replace yours.
  • 304 Not Modified with no body at all — your copy is still current, serve it.

That 304 is the efficient heart of HTTP caching: a tiny header-only response that saves resending an unchanged megabyte. It's also, for a nonced page, a loaded gun.

A 304 Not Modified tells the cache to serve the body it already has. For a page whose body contains a one-time nonce, "the body it already has" is the old nonce — already used, now reused.

Why a nonce breaks all of this

Now we can assemble the problem. A CSP nonce is a random token the server generates fresh for every response and stamps onto both the policy header and the script tags it trusts; the browser runs only scripts carrying that response's nonce. The whole security property rests on the token being unique per response and unguessable.

A cache's entire job is to reuse a stored response. These two goals are in direct opposition, and it's worth being precise about exactly how each caching directive fails:

Only no-store is safe for a nonced page. Each bar is "does this directive guarantee a fresh nonce on every response?" — 0 for no, 1 for yes. The first three all permit a stored body to be re-served.
  • max-age / shared caching. If a CDN stores the page for an hour, every visitor in that hour gets the same HTML — the same nonce. It's now effectively public: an attacker fetches the page, reads the nonce, and injects <script nonce"…">= that the browser will happily run. Caching a nonced page across users hands the nonce to all of them.

  • private. Tempting, because it stops the CDN. But it explicitly lets the browser keep the page, and the HTTP cache can re-serve that stored body — same-tab re-navigation, heuristic reuse — complete with its stale nonce. (Back/forward is a different animal: the bfcache restores a frozen snapshot of the live document, HTTP directives notwithstanding — RFC 9111 §6 says outright that "a history mechanism can display a previous representation even if it has expired." The scripts there already ran, so it re-shows a page rather than re-serving a nonce.) private fixes where the copy lives; it doesn't stop the copy from being reused.

  • no-cache (the obvious-looking fix). "It revalidates every time, so the browser always rechecks with the origin and gets a fresh nonce — right?" No. Revalidation is a conditional request, and the origin can answer 304 Not Modified, at which point the cache serves its stored body — the old nonce — while the response's new CSP header carries a new nonce. The two now disagree: the scripts are blocked, the page breaks, and even in the case where they happen to match, you've reused a nonce, which is precisely the failure the nonce existed to prevent. A nonced page's honest answer to "is the body still current?" is always no — which is another way of saying it must never be revalidated, only re-fetched.

private governs which cache stores the body. no-cache governs when the stored body is rechecked. Neither stops the stored body from being reused — and a reused body is a reused nonce. Only no-store forbids keeping the body at all.

So the minimum correct header for HTML that carries a per-response nonce is the strong one:

Cache-Control: no-store

That's it. no-store forbids every cache, private or shared, from storing the body, forcing a fresh origin response — new body, new nonce, new matching header — on every request. (Forbids, not guarantees: RFC 9111 is explicit that a directive a cache should obey is not a privacy mechanism — a broken or hostile cache can still keep bytes. For correctness reasoning, though, no-store is the strongest instruction the protocol has.) You'll sometimes see no-store, no-cache, must-revalidate piled up for ancient intermediaries; it's harmless belt-and-suspenders, but no-store alone is what the semantics require.

Warning

Pragma: no-cache on a response is unspecified. You'll find it in nearly every "disable caching" snippet, so an implementer needs to know why it's absent here. Pragma is an HTTP/1.0 header whose only defined value, no-cache, is specified solely as a request directive — a client saying "don't hand me a cached copy." RFC 9111 §5.4 says its meaning in a response "was never specified" and deprecates the header outright; a cache may happen to honor it, but nothing requires it to, and even on a request it's overridden the moment Cache-Control is present — which on any cache built this millennium it always is. So Pragma: no-cache stamped on your response is a token you cannot rely on any cache to read. Set Cache-Control: no-store and skip it.

The escape hatches: how teams keep their cache

"So a strict CSP means I can't cache my HTML" is the despairing conclusion, and it's why so many static-site and edge-first teams quietly skip nonces. But there are two real ways out, and they're the reason the trade-off is livable.

Vouch by hash, not by nonce

A CSP hash (sha256-…) vouches for a script by the fingerprint of its bytes, not by a per-response token. Because the script's content is identical on every response, the hash is stable — so it can sit in a CSP header generated once at build time and served from a CDN forever. Google's Strict CSP guide makes the split explicit: nonces for server-rendered pages, hashes for pages served statically or that need to be cached.

The catch: hashes cover only the inline scripts you can fingerprint at build time. Scripts a trusted script loads at runtime still need strict-dynamic to inherit trust — which a hash propagates the same way a nonce does. Hashes change the caching story, not the propagation one.

So a fully static page can ship Cache-Control: public, max-age=… alongside a hash-based policy, and lose nothing. This is the right default for a content site (this one included).

Inject the nonce at the edge

When you do need nonces — server-rendered apps, per-user pages — you can move the per-response mutation from the origin to the edge. A CDN worker (Cloudflare's HTMLRewriter, Akamai EdgeWorkers, Fastly Compute, a Vercel or Netlify middleware) generates a fresh nonce per request and rewrites both the <script> tags and the CSP header as the response streams through. The origin artifact stays static and cacheable up to the edge; only the final hop is unique.

Edge-injected nonces relocate the conflict, they don't remove it. The origin HTML is cacheable up to the edge worker; the worker stamps a fresh nonce per request, so the response it delivers is effectively no-store from there to the browser.

It's worth being honest about what this buys: it makes the origin cacheable, not the delivered response. The edge must still produce a unique response per request — the last hop is no-store in spirit whether or not you write the header. What you've saved is the origin round-trip and the render cost, which on a heavy page is most of the win. The conflict between "one-time token" and "reuse a stored copy" is fundamental; you can move where it's paid, but someone on the path always serves something uncacheable.

You cannot cache a one-time value. A nonce and a cached body are a contradiction in terms — so you either stop using nonces for cacheable pages (hash instead), or push the nonce to the last uncacheable hop (the edge). There is no third option, because there is no such thing as a reusable unique number.

One more real-world wrinkle belongs on the record, because it prices the advice. no-store historically also cost you the back/forward cache — browsers declined to bfcache pages that carried it, so the "correct" header for nonced pages made back-navigation slower. Chrome has been walking that back: it now allows Cache-Control: no-store pages into the bfcache "when this is safe to do" — trialed in 2023, fully rolled out by spring 2025 — evicting the snapshot if cookies or authorization change, and shortening its timeout. Firefox and Safari still treat no-store as bfcache-blocking (WebKit has raised shared-device concerns with Chrome's approach). Two consequences: the performance tax of no-store is shrinking in Chrome, and — the flip side — a bfcache restore re-shows your page without re-running its scripts or re-evaluating CSP, which is fine for nonces (they already did their job) but worth knowing when you reason about "every view got a fresh response."

What to actually do

The decision is small once the mechanism is clear:

  • Static / content site. Use hashes, ship public, max-age=…, immutable on fingerprinted assets and a sensible max-age on HTML. Cache freely.
  • Server-rendered, per-response nonce at the origin. Set Cache-Control: no-store on the HTML. Accept that the document is uncacheable; cache the assets (which are hashed and immutable) instead — that's where the bytes are anyway.
  • Want both caching and nonces. Inject the nonce at the edge, cache the origin, and let the edge serve the unique last hop.

The header that can't be cached, then, isn't really Cache-Control — it's the nonce it's protecting. Cache-Control: no-store is just the honest acknowledgement that you've asked for a value that is, by construction, new every time.

Lessons

  • Cache-Control governs who may store a response and for how long, separating the browser's private cache from shared caches (CDN, proxy).
  • no-cache does not mean "do not cache"; it permits caching but mandates revalidation with the origin server before every reuse.
  • no-store forbids any HTTP cache from keeping a copy — the right directive for nonced pages. Service-worker caches and the bfcache sit outside HTTP caching and follow their own rules.
  • A nonce is a one-time secret, and a cache exists to reuse things: a cached nonced body either leaks a reusable nonce or (after a 304 refresh) disagrees with its own CSP header. Nonced HTML must be re-fetched, never revalidated.
  • Push nonces to the edge (using CDN workers) to keep the origin HTML cacheable up to the edge, or switch to hash-based CSPs for fully static content.

References

  1. RFC 9111: HTTP Caching.” RFC Editor. — the directives, the shared/private distinction, and conditional requests, from the source
  2. MDN: Cache-Control.” MDN. — the most readable directive-by-directive reference, with the no-cache \ne no-store warning called out
  3. web.dev: Mitigate XSS with a strict CSP.” web.dev. — the nonce-vs-hash split by render mode
  4. Trust No Script.” — the companion piece on what a strict CSP is and how to read one
  5. Cache-Control cheatsheet.” — the one-page decision version of this essay

How to cite

APA
Mangalapilly, Y. J. (2026, July). The Header That Can't Be Cached. Saṃhitā Notes. https://yesudeep.com/blog/the-header-that-cant-be-cached/
BibTeX
@online{mangalapilly2026the,
          author  = {Yesudeep Jose Mangalapilly},
          title   = {The Header That Can't Be Cached},
          journal = {Sa\d{m}hit\=a Notes},
          year    = {2026},
          month   = {July},
          url     = {https://yesudeep.com/blog/the-header-that-cant-be-cached/},
          urldate = {2026-07-03},
        }
Plain
Yesudeep Jose Mangalapilly. “The Header That Can't Be Cached.” Saṃhitā Notes, 2026. https://yesudeep.com/blog/the-header-that-cant-be-cached/.
RIS
TY  - ELEC
        AU  - Mangalapilly, Yesudeep Jose
        TI  - The Header That Can't Be Cached
        T2  - Saṃhitā Notes
        PY  - 2026
        UR  - https://yesudeep.com/blog/the-header-that-cant-be-cached/
        Y2  - 2026-07-03
        ER  - 

Annotations

Thank you — your note is held for review and will appear once approved.

Thank you — your note is published.

Please sign in below to leave a note.

Type to search · ↑↓ to move · ↵ to open · Esc to close