Making a CSP hash a build output instead of a hand-maintained one
I found this bug by accident, while verifying a completely unrelated analytics change. A browser-verify pass surfaced a Content-Security-Policy violation that had nothing to do with what I was testing. I could have shrugged it off as noise. Instead I traced it, and it turned out the “Launch Planetary Pathways” button on the homepage had been dead in production for two days, with zero visible error to anyone who wasn’t looking at devtools.
The bug: a hash that was true once
This site’s CSP allowlists exactly one inline script by sha256 hash - the click handler in GamesSection.astro that reveals the game iframe. That hash got set once, back when the security headers first went in, and nobody had touched it since. Then an unrelated i18n pass on that same component changed its compiled output by a few bytes. The hash stopped matching. astro check stayed green. npm run build stayed green. The only place this showed up was a browser console, on a page nobody was running devtools on.
Two days is a long time for a real feature to be quietly broken with nothing catching it.
Why does one script get hashed and another doesn’t?
This site also has scripts that don’t need a hash - the consent-gate scripts from @vdaluz/astro-opt-in-analytics get compiled into external files instead, which sidesteps the whole problem. I assumed at first this was about import statements: scripts that import something get bundled externally, plain scripts stay inline. Wrong guess. Astro’s own docs say the real rule: small processed scripts get inlined directly into HTML “to reduce the number of requests.” There’s no documented size threshold, no config flag to opt out of it. The game-launch script is tiny, so it gets inlined. The consent-gate scripts happen to pull in enough code through their imports to cross whatever threshold Astro uses internally, so they get externalized as a side effect of being bigger - not because anyone asked for that.
That distinction matters because it means you can’t reliably force a script to externalize just by restructuring it. And if a script stays inline, any hardcoded hash for it is one edit away from going stale, silently, forever.
The fix: stop hand-maintaining the hash at all
Recomputing the hash once would have fixed today’s bug and set up the exact same bug for whenever this component gets touched next. So instead of a patch, I wrote a small Astro integration that runs after every build: it scans the actual generated HTML for inline scripts, hashes whatever it finds, and writes those hashes into the deployed _headers file. The CSP hash is no longer something a person remembers to update - it’s a build output, computed fresh every time, matching whatever Astro actually shipped.
Building it surfaced one more thing worth knowing: my first version also hashed a completely unrelated file, the vendored Unity WebGL build’s own HTML, which already has its own separate CSP override with unsafe-inline. The scan needed an explicit exclusion for that path, or every Unity engine upgrade would silently add a stray hash to the main site’s policy for no reason.
What I’d keep doing
The dev server almost sent me down the wrong path here too - a CSP check against npm run dev throws a completely different, unrelated violation, because Astro bundles scripts differently in dev mode. If I’d trusted that first result I’d have chased a phantom. Building and previewing the real production output before trusting a “this looks fixed” result is what actually caught both the original bug and my own integration’s first mistake.
Related reading
The CSP error that wasn't mine to fix
A launch-day checklist, a Content Security Policy violation naming the exact script I'd just edited, and a comment in the repo that would have saved me the whole detour.
The CSP that only broke in production
Four failures behind one header: dead middleware, _headers rules that merge instead of override, a blob-URL worker under script-src, and a script only the edge injects.
The filter pills existed, just not where anyone could find them
Per-project filtered views with a nice pill nav - reachable only from homepage cards. The main /blog index, where everyone actually lands, had no way in at all.