How I Gave CloudFront a Content-Security-Policy Without Blanking My Own Site
A strict Content-Security-Policy on a static site allowlists inline scripts by hash. Change one script, forget to update the hash, and every page renders blank. The incident that taught me, and the deploy guard that ends it.
The site went blank on a Tuesday. Not down. Blank. The images loaded, the favicon loaded, and everything else was white. No menu, no text, no error. Locally it was perfect. I had deployed maybe forty times the same way. This time the homepage came back as a clean white rectangle with three pictures floating in it.
It was not a build failure and it was not a bad deploy. It was a security header doing exactly what I had told it to do.
What I had done
A few weeks earlier I had put a real Content-Security-Policy on the site. The kind without unsafe-inline. If you are serving a static site from S3 behind CloudFront, a strict CSP is one of the highest-value things you can add, because it turns a whole class of injection attacks into a non-event. The browser will only run scripts you have explicitly blessed.
The way you bless an inline <script> block is by its hash. You take the exact bytes between the tags, run them through sha256, base64 the result, and put 'sha256-...' in the policy. The browser hashes every inline script it finds and runs only the ones whose hash is on the list. One character different, different hash, blocked.
That is the trap, and I walked straight into it.
Why it broke
I had edited one inline script. It is the small block in my base layout that sets the theme before the page paints, so dark-mode users do not get a white flash on every navigation. To do that without a flash, the script starts the page at visibility: hidden and then clears it once the theme is applied. Hide first, decide, reveal.
I changed one line in that block. Reformatted it, really. The behavior was identical. But the bytes were not, so the hash was not, and the CloudFront policy still listed the old hash. The browser found the script, hashed it, did not find a match, and refused to run it.
The script that never ran was the one whose entire job was to undo visibility: hidden. So every page loaded its full HTML, sat there correctly structured and completely invisible, and waited for a reveal that the browser had just blocked. View-source showed all the content. The console showed the CSP violation. The page showed nothing.
The fix, and then the real fix
The immediate fix is mechanical. I have a script, hash_inline_scripts.py, that walks dist/, pulls every inline script that is not a JSON-LD data block, computes the sha256, and prints the allowlist. You run it after a build, take the new hashes, and update two places: the live CloudFront Response Headers Policy, and a response-headers-policy.json checked into the infrastructure repo so the policy is version-controlled instead of living only in the AWS console. Mirror them or they drift, and a drifted security policy is a bug you cannot see in the diff.
That fixes the symptom. It does not fix the part where the whole thing depended on me remembering to do it at the exact moment I was thinking about something else.
So the real fix is a guard. pre-deploy-csp-check.py runs before anything uploads. It scans the build for inline scripts that appear on a lot of pages, the common ones like the theme block and the navigation, because those are the scripts that take the entire site down rather than one page. For each common script it checks whether its hash is present in the live policy. If a hash is missing, the deploy fails. Loudly. Before a single file moves.
The deploy now refuses to ship a site that would render blank. That is the sentence I actually wanted. Not "be more careful." The careful version had already failed once and it will fail again, because careful is a feeling and a build step is a fact.
What I would tell you
If you put a strict CSP on a static site, and you should, understand that inline scripts plus hash allowlisting is a sharp edge and plan for it on day one. You have two clean ways out. Move every script into an external bundled file so there is nothing inline to hash, which is the simplest if you can pay the small no-flash-theme cost. Or keep the few inline blocks you actually need, automate the hashing, and put a guard in front of the deploy so a stale hash can never reach production. I kept two inline blocks for the theme load and bought the safety with the guard.
The general lesson is older than CSP. The most dangerous failures are the ones where the system is working perfectly and the configuration is wrong, because nothing throws, nothing logs an error, and the tool you would reach for to debug it is reporting success. A blank page with a 200 status and all its HTML intact is that failure wearing a costume. When something is impossible, check the thing that is succeeding.
This is the same loop I run on everything now: when a failure teaches you something, you do not just fix it, you build the system that makes that failure impossible to ship again. I wrote about that habit in Recursive DevOps, and about rebuilding this whole site under that discipline in Rebuilt My Site in Agent Mode. If you are choosing tools for this kind of work, my honest comparison of the two I lived in is Claude vs Copilot for DevOps. The rest of the field notes live at AI-assisted engineering. The smallest place to start, though, is to go read your own deploy script and ask it one question: what is the worst thing it will let you ship without complaining?