A strict Content-Security-Policy for Astro: hashing inline scripts instead of 'unsafe-inline'

When I set up this blog I wanted a tight Content-Security-Policy - the kind that starts at default-src 'none' and only opts back in what the site actually needs. The wall I hit immediately is that Astro ships inline <script> blocks you never wrote yourself, and the most common advice for dealing with them is to add 'unsafe-inline' to script-src. That would undo the entire point of having a CSP for scripts, so I went a different route.

Why not just allow ‘unsafe-inline’?

A meaningful Content-Security-Policy starts from default-src 'none' - nothing is allowed until you explicitly permit it. For scripts, script-src 'self' lets the browser load your external .js files from the same origin, which covers most of what Astro produces. But 'self' does not cover inline <script> blocks.

The tempting shortcut is script-src 'self' 'unsafe-inline'. The problem: that re-permits every inline script on your pages, including any an attacker manages to inject. It effectively turns off the main XSS defense a CSP exists to provide. You have gone through the trouble of writing a strict policy and then punched a hole through the one directive that matters most.

Where Astro’s inline scripts come from

An Astro site with even minimal interactivity will ship a few inline scripts you did not hand-write. On this blog there are two sources.

The no-FOUC theme script

The site supports light and dark mode, toggled by a button and persisted in localStorage. To prevent a flash of the wrong theme on page load, BaseHead.astro includes a small is:inline script that runs before the first paint:

const stored = localStorage.getItem("theme");
const dark = stored
  ? stored === "dark"
  : matchMedia("(prefers-color-scheme: dark)").matches;
document.documentElement.classList.toggle("dark", dark);

This script is author-controlled, but it must be inline. Moving it to an external file means the browser fetches, parses, and executes it later - reintroducing the flash of wrong-themed content it exists to prevent.

Astro’s island runtime

The theme toggle itself is a Svelte component (ThemeToggle.svelte) hydrated with client:load. To bootstrap that island, Astro injects small inline scripts at build time. These are not authored by you - they are generated by Astro’s build pipeline - but they are inline <script> blocks all the same, and a strict CSP blocks them.

What about JSON-LD?

BaseHead.astro also emits <script type="application/ld+json"> blocks for structured data. These are not executable - script-src only governs scripts the browser would execute (type="module", type="text/javascript", type="application/javascript", or no type at all). Data scripts like application/ld+json, importmap, and speculationrules are exempt, so they need no hash. The hash generator deliberately skips them.

The fix: a SHA-256 hash per inline script

CSP’s script-src directive accepts hash sources in the form 'sha256-<base64>'. The browser computes a SHA-256 hash of each inline script’s text content and only executes it if the hash appears in the policy. So the task is: enumerate every inline script your build emits and pin its hash.

Doing this by hand is error-prone and tedious, so I wrote a small build script (scripts/csp-hashes.mjs) that automates it. The core logic:

  1. Walk every .html file in dist/.
  2. Match every <script> tag.
  3. Skip scripts with a src= attribute - those are external files, already covered by 'self'.
  4. Skip non-executable type values - application/json, application/ld+json, importmap, speculationrules, and anything else that is not module, text/javascript, application/javascript, or untyped.
  5. Skip empty bodies.
  6. SHA-256 the body text, base64-encode it, and collect the result in a Set (deduplication - the same island bootstrap appears on every page).
  7. Print the ready-to-paste script-src directive.

The actual hash computation is one line of Node:

createHash("sha256").update(body, "utf8").digest("base64");

The workflow:

bun run build && bun run csp:hashes

The script prints a complete script-src 'self' 'sha256-…' 'sha256-…' … line. Paste it into your _headers file and you are done.

Wiring it into Cloudflare Pages headers

Cloudflare Pages reads a public/_headers file and applies the headers to matching routes. The CSP directive for this blog looks like:

Content-Security-Policy: default-src 'none'; script-src 'self' 'sha256-…' 'sha256-…' 'sha256-…'; style-src 'self' 'unsafe-inline'; img-src 'self' data:; font-src 'self'; connect-src 'self'; manifest-src 'self'; base-uri 'none'; form-action 'none'; frame-ancestors 'none'

A note on honesty: style-src still carries 'unsafe-inline'. Astro and Tailwind emit inline styles, and hashing every inline style attribute across every page is not practical the way hashing a handful of <script> blocks is. Scripts are the high-value target - they execute code, exfiltrate data, and are the primary vector for XSS. Locking down script-src while tolerating 'unsafe-inline' for styles is a reasonable trade-off, not a perfect one.

The gotcha: hashes are byte-exact

This is the part that will bite you six months from now if you forget it. SHA-256 hashes are computed against the exact bytes of the inline script body. Any change - even a cosmetic one - produces a different hash. Triggers include:

  • Upgrading Astro. A new version may emit slightly different island bootstrap code.
  • Editing a client:load component. Changes to the Svelte component can change the inline bootstrap Astro generates for it.
  • Reformatting the theme script. If Prettier rewrites its quotes or indentation, the bytes change and the hash is invalid.

The failure mode is the nasty kind: it is silent. The build stays green. The site looks fine locally because bun run dev does not enforce the _headers CSP. But in production, the browser blocks the inline scripts whose hashes no longer match, and your interactive components - like the theme toggle - quietly stop working.

The discipline is simple: after any Astro upgrade, any change to a hydrated component, or any edit to the theme script, re-run:

bun run build && bun run csp:hashes

Paste the updated script-src line into public/_headers and commit both.

FAQ

Why not use a nonce instead of hashes?

Nonces require injecting a fresh random value into both the CSP header and every inline <script> tag on each response. That needs a server. A fully static site deployed to Cloudflare Pages has no per-request server step to mint one, so hashes are the only viable approach for static output.

Why not move everything to external script files so 'self' covers it?

The no-FOUC theme script must run inline before first paint - deferring it to an external file reintroduces the flash. Astro’s island bootstrap is inline by design and not something you control. Hashing is the practical route when you cannot eliminate the inline scripts.

Does adding a blog post or page mean I have to regenerate hashes?

No. The hashes depend on the inline scripts Astro emits, which are determined by your components and Astro’s build pipeline - not by your content. Adding or editing a Markdown or MDX file does not change the emitted scripts, so the hashes stay valid.

How do I spot a stale hash?

In production, the theme toggle stops toggling even though the build succeeded and everything works in local dev. That is the tell - open the browser console in production and you will see CSP violation errors for the blocked inline scripts.