The CSP that only broke in production
Adding security headers should have been the boring issue. No headers existed at all, so I had a suggested policy already worked out from a previous spike, and the task looked like copy-pasting a Content-Security-Policy string into a config file. It didn’t stay boring.
The first surprise was structural. This site is Astro on Cloudflare Workers, and the plan called for two mechanisms: middleware for server-rendered pages, a _headers file for static ones. I went to write the middleware and checked something first: every single route on this site is prerendered. Not most of them, all of them. Middleware in Astro only runs for pages rendered on demand, and there are none. I wrote it anyway, for whenever a real dynamic route shows up, but I labeled it honestly: it does nothing today. The _headers file is the only thing touching a single live request.
The second surprise came from actually reading Cloudflare’s docs instead of assuming I understood _headers syntax. I wanted one strict policy everywhere, and a looser one just for the folder holding a Unity WebGL game, since WebAssembly needs permissions a normal page doesn’t. My instinct was to add a second rule for that folder with the extra permissions appended. Cloudflare’s docs stopped me: rules that match the same request don’t override each other, they merge, and duplicate headers get joined with commas. A browser reading two comma-joined CSPs doesn’t pick one, it enforces the intersection, the strictest version of every rule. My “looser” policy for the game would have been silently strangled back down to the strict one. There’s an actual unset syntax for this, a bang before the header name, that clears what came before so you can write something genuinely different. Without checking, I’d have shipped a policy that looked permissive in the file and behaved exactly as unpermissive as the one it was supposedly loosening.
Even with that fixed, the header looking right in a text file told me nothing about whether the game would run. So I loaded it. Headless Chrome, pointed straight at the game’s own HTML document, reading whatever the console actually said. The first run failed immediately: the game ships its own small inline script, and my policy didn’t allow inline scripts. I opened it back up and allowed it, for that one path only. Second run, later, failed again, this time deeper in - Unity spins up a web worker from a blob URL, and it turns out permission to create the worker isn’t the same permission the worker needs to load its own code. That second one lives under script-src, not worker-src, and nothing about the first failure would have told me that; I only found it because I let the page run long enough to reach that step.
The last failure was the one no local test could have caught. Everything passed against the real Cloudflare runtime running on my machine. I merged, deployed, and pointed a headless browser at the actual live site to close the loop - and it failed on something new: Cloudflare itself injects an analytics script into every real response, a step that only happens on their edge, never in local preview. My policy didn’t know that script existed because there was no way to know, short of hitting production.
Four different failures, four different reasons a header that “looks right” wasn’t, and every one of them only surfaced because I insisted on watching the actual page load instead of trusting that the file was correct. The last fix went out as a quick follow-up push once the site was already live, allowed the one extra script, and confirmed clean.
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.
Making a CSP hash a build output instead of a hand-maintained one
A game launch button broke silently two days before anyone noticed. The fix wasn't recomputing a hash - it was making sure nobody ever has to again.
Closing the loop on the Open Source section, and the 404 that couldn't run server code
Localizing an error page with no server involved at all, a path check that would have broken on /estimates, and infrastructure I shipped knowing it does nothing yet.