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 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.
Fixing the stale GitHub pinned-repo list
A comment claiming the list 'mirrors the pinned repos' was true once. A hardcoded list with no drift check eventually lies about what it mirrors.
A one-line token swap that found a broken button and a GDPR gap
Fixing a placeholder analytics token turned into a lesson about how CSP hashes go stale silently, and why a dev server can lie to you about production behavior.
You might also find useful
NordPass
Password manager from the team behind NordVPN, with a free tier.
As a NordPass affiliate, I earn from qualifying purchases.
Learn moreAdGuard for iOS
System-wide ad and tracker blocking on iOS, no separate DNS server required.
As an AdGuard affiliate, I earn from qualifying purchases.
Learn moreProton Pass
Privacy-focused password manager from the team behind Proton Mail.
As a Proton Partner, I earn from qualifying purchases of Proton's privacy and security services (Pass, Mail, VPN, Drive).
Learn more