billahdotdev
2026-09-16

The refresh that stripped my own website

A strict content security policy, a conditional request, and a page that renders perfectly once and then arrives naked.

I shipped a site with a strict content security policy. No inline scripts allowed, no inline styles allowed, unless they carry a nonce that the server mints fresh for every single request. The style block is inlined into the HTML, the worker rewrites it with today's nonce on the way out, and nothing else on the page is permitted to execute at all.

It worked. Then I pressed refresh, and the page came back as raw unstyled markup, like a 1994 homepage that had given up.

Read the rest

Why it only breaks the second time

The first request is simple. The server builds the HTML, generates a nonce, stamps it into the style tag and into the policy header, and both halves agree.

On the refresh, the browser already has that HTML cached, so it does not ask for the page. It asks whether the page changed, sending along the identifier it was given last time. The asset layer looks at that identifier, sees that the file on disk has not changed, and answers with a polite nothing at all.

That nothing carries a fresh policy header, because headers are generated per request. The body it does not carry is the old one, with the old nonce still inside it. New policy, old nonce, no match. The browser does exactly what it was told to do and refuses to apply the styles.

Every piece of this is behaving correctly. The bug lives in the gap between them.

Two things that do not mix

A nonce is a promise that a value is used once. A cached response is a promise that a value is reused. Serving nonce-protected HTML with any form of revalidation is asking those two promises to hold at the same time.

Once you see it that way the fix stops being clever and starts being obvious. The HTML must never be revalidated. It must be regenerated.

So the conditional headers get stripped from the request before it reaches the asset layer, which means the answer is always a full body rather than a not-modified. And the response goes out with caching switched off for the document, with the validators removed, so the browser never tries the shortcut in the first place.

The cost, and why it is nothing

The obvious objection is that you have given up caching your own HTML. True. The page here is a couple of dozen kilobytes, compresses to a fraction of that, and comes from an edge location a few milliseconds away. Meanwhile the fonts, images and everything else with a stable name are cached for as long as the browser will hold them, because none of them carry a nonce.

You lose a rounding error and you get a policy that is actually enforced instead of decorative.

The thing worth taking away

This class of bug never appears in development, because the cache is empty and the server is restarting constantly. It appears for the second visitor, on the second visit, on a device you do not own.

Test the second load. Then test it again after a deploy. The first load tells you the code works. The second one tells you the system works.

Writing Back to the start