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?
Update, June 2026: I took the simpler way out, and the guard earned its keep
I am leaving the original below rather than quietly editing it, because the change of mind is the useful part.
When I wrote this, I kept two inline blocks for the theme load and bought safety with the deploy guard, and I called moving everything to external files the simpler way out that I had chosen not to take. Then I went to upgrade the framework, and the guard caught exactly what it was built to catch: the new version re-minified one of those inline scripts on its own, the hash changed, and the deploy stopped itself before it could blank the site. The cost I had dodged by keeping the scripts inline came due, on a change I did not control. It was a strange thing to be happy about a failed deploy, but that is the whole point of the guard, and here it was doing the job in the wild.
So I took the way out. The interactive scripts now live in their own files, served from the site itself, with nothing inline left to hash. The one piece I cared about most, the small block that reveals the page once the theme is set, got its own tiny file on purpose, so a bug anywhere else can no longer leave the page hidden. The no-flash cost I had braced for never really showed up, because that reveal was already waiting for the page to parse before it ran. I had been carrying the fragility without getting much for it.
That groundwork paid off sooner than I expected. The larger upgrade I had been circling finally landed, the kind that rewrites a lot of generated code in one pass. Because the scripts were already external, there was almost nothing to re-bless by hand, just one small file the framework generates for an interactive page, where before it would have been a scramble across the whole site. A second check earned its keep the same day: the one that pings a short list of must-work pages after every deploy caught a page that had quietly changed address during the upgrade, and stopped the deploy before anyone could land on the empty spot. Fix, rebuild, confirm. The lesson did not change, it just got tested at a bigger size. The job was never to be more careful. It was to build the thing that is careful for me, and then to actually listen when it complains.
What is left inline now is one stable block that the framework copies through untouched, so a version bump can no longer change a hash out from under me. The guard stays, but it has almost nothing left to guard. The setup in the original post was not wrong. It was the right call for the moment and the wrong call for the next one, which is most engineering decisions if you keep them long enough.
Then I took the last step, and it is the part I am happiest with. A guard can only ever tell you a hash is missing; you still have to go fix the list. So I stopped keeping the list by hand at all. The deploy now reads the finished site, works out the exact set of scripts it actually contains, and writes that into the policy itself, every time, before anything goes live. A changed script, a new one, even a generated one I did not write by hand gets blessed on the way out the door. The list cannot fall behind the site, because the site is now what produces the list. That is the whole arc of this post in one move: I started by being careful, got burned, built a thing to catch me when I was not, and finally handed the care to the build entirely. The thing I wanted all along was not a safer habit. It was a system that stays correct without me remembering to keep it that way.