Hash-Based CSP for Inline Scripts in Astro

Most real-world Content Security Policies contain 'unsafe-inline' in script-src, and most of them contain it for the same reason: the site has a few inline scripts — a theme toggle that must run before first paint, an analytics loader, a small bootstrap — and listing them properly seemed like too much work. But 'unsafe-inline' also allows any script an attacker manages to inject, which is the main thing CSP exists to prevent.

Static sites have an unusually clean way out. Their HTML is fixed at build time, so every inline script can be hashed during the build and listed in the policy by its SHA-256 digest. The browser then runs inline scripts whose content matches a listed hash and blocks everything else. This guide automates that for an Astro site with a post-build script that hashes the final HTML and writes the header. The policy process around it is in Writing a Content Security Policy for a Static Site.

Prerequisites

  • An Astro site with static output, deployed to a host that reads a _headers file (Cloudflare Pages, Netlify) or equivalent.
  • Node.js 20 or later for the post-build script.
  • A draft CSP for everything except inline scripts.

How Astro Produces Inline Scripts

Astro processes <script> tags in components: by default it bundles them into external module files, which script-src 'self' already allows. Inline scripts in the output come from three places:

  • <script is:inline> in your components — usually deliberate, such as a theme bootstrap that must run before paint.
  • Integrations and framework islands — the island hydration bootstrap (astro-island definitions) and some integrations' loaders are inlined.
  • Small hoisted scripts Astro chooses to inline when they are below build.inlineStylesheets-style thresholds or when a directive requires it.

On the 700-page docs site, the built output contained four distinct inline scripts across all pages, each appearing on hundreds of pages with identical content.

# count distinct inline scripts across the build
node -e "
const fs=require('fs'),glob=require('fs').globSync;
const set=new Map();
for (const f of glob('dist/**/*.html')) {
  for (const m of fs.readFileSync(f,'utf8').matchAll(/<script(?![^>]*\bsrc=)[^>]*>([\s\S]*?)<\/script>/g))
    set.set(m[1], (set.get(m[1])||0)+1);
}
for (const [s,n] of set) console.log(n, s.slice(0,60).replace(/\s+/g,' '));
"
712 (function(){try{var t=localStorage.getItem("theme")...
712 (()=>{var e=async t=>{await(await t())()};(self.Astro||...
391 (()=>{var l=(n,t)=>{let i=async()=>{await(await n())()}...
 12 window.dataLayer=window.dataLayer||[];function gtag(){...
From build output to hashed policy Astro builds the site to dist. A post-build script scans every HTML file, extracts each inline script's exact text, computes a SHA-256 hash in base64 and collects the unique set. It writes those hashes into the script-src directive of the _headers file. The host serves the header; the browser runs only inline scripts whose hash matches. Hashes are computed from the exact bytes that ship astro build 712 HTML files extract inline 4 unique scripts sha256 → base64 'sha256-…' × 4 _headers script-src an injected inline script has no listed hash → blocked The step runs after every build, so an edited script gets a new hash automatically
Because the hashes come from the final output rather than from source files, integrations and minification cannot put them out of step.

Step 1: Write the Post-Build Script

The script hashes each inline script's exact text content — the bytes between <script> and </script>, whitespace included — because that is what the browser hashes.

// scripts/csp-hashes.mjs
import { createHash } from 'node:crypto';
import { globSync, readFileSync, writeFileSync } from 'node:fs';

const INLINE = /<script(?![^>]*\bsrc=)(?![^>]*type="(?:application\/ld\+json|speculationrules)")[^>]*>([\s\S]*?)<\/script>/g;
const hashes = new Set();
for (const file of globSync('dist/**/*.html')) {
  for (const [, body] of readFileSync(file, 'utf8').matchAll(INLINE)) {
    if (!body.trim()) continue;
    hashes.add(`'sha256-${createHash('sha256').update(body, 'utf8').digest('base64')}'`);
  }
}

const policy = [
  "default-src 'self'",
  `script-src 'self' ${[...hashes].sort().join(' ')} https://plausible.io 'wasm-unsafe-eval'`,
  "style-src 'self'",
  "img-src 'self' data: https://img.example-cdn.net",
  "connect-src 'self' https://plausible.io",
  "frame-ancestors 'none'", "base-uri 'self'", "object-src 'none'",
  'report-to csp-endpoint',
].join('; ');

const headers = readFileSync('dist/_headers', 'utf8')
  .replace('__CSP__', policy);
writeFileSync('dist/_headers', headers);
console.log(`[csp] ${hashes.size} inline script hash(es)`);

JSON-LD blocks and speculation rules are excluded: they are data, not executable script, and CSP does not require them to be listed. The _headers template in public/ contains a __CSP__ placeholder the script fills in:

# public/_headers
/*
  Content-Security-Policy: __CSP__
  Reporting-Endpoints: csp-endpoint="/api/csp-report"

Add the script to the build: "build": "astro build && node scripts/csp-hashes.mjs".

Step 2: Remove What Should Not Be Inline

Hashing works for any inline script, but the fewer there are, the easier the policy is to reason about. Two of the four were removable:

  • The Google Tag Manager snippet (12 pages) was a leftover from a campaign. Removed.
  • The island bootstrap on 391 pages is Astro's own and changes only when Astro upgrades. It stays, hashed.

The theme bootstrap must stay inline — it sets the data-theme attribute before first paint to avoid a light flash in dark mode — so it stays and is hashed.

Inline event handlers (onclick="…") are not covered by script hashes without 'unsafe-hashes'. The site had two, in a Markdown-embedded HTML snippet; both were replaced with an addEventListener in a bundled script.

Step 3: Verify Before Enforcing

Deploy with the header name Content-Security-Policy-Report-Only first and load each template with DevTools open. A hash mismatch appears as a console error naming the hash the browser computed, which is the fastest way to spot a script whose content differs between pages (for example, one that includes a page-specific value). Scripts that vary per page need either per-path policies or to be rewritten to read the variable from a data attribute, so the script body stays identical everywhere:

<!-- before: body differs per page, hash differs per page -->
<script is:inline>window.PAGE_ID = "guides/deploying-hugo";</script>
<!-- after: body identical everywhere, value read from the DOM -->
<body data-page-id="guides/deploying-hugo">
Per-page script bodies versus a shared body Before, 38 pages each had an inline script with a different page ID embedded, producing 38 different hashes. After, the page ID moved into a data attribute and every page shares one identical script, producing a single hash. Keep inline script bodies identical across pages value inside the script hash A hash B … hash 38 38 hashes, policy grows per page value in a data attribute one shared hash 1 hash, one site-wide policy Reading values from the DOM keeps the script cacheable and the policy short
A per-page value inside an inline script is the usual reason hash lists explode; moving it to the markup fixes both problems.

On this site, 38 pages had carried a page-specific analytics label inline; after the change, the site-wide header needed a single hash for that script, and the header itself stayed under 700 bytes. Header size matters more than it seems: some CDNs and proxies reject or truncate response headers above 8 KB, and a policy with hundreds of per-page hashes can approach that.

Measured Impact

MeasureBeforeAfter
script-src'self' 'unsafe-inline' https:'self' + 3 hashes + 1 origin
Distinct inline scripts in build43
Pages with a page-specific inline script380
Post-build script run time1.4 s (712 pages)
Mozilla Observatory CSP testfail (unsafe-inline)pass
Hash-mismatch reports after enforcing (4 weeks)0
What the policy permits, before and after Two panels. Before, script-src allowed any inline script and any HTTPS origin, so an injected inline script and a script from an attacker's domain would both run. After, only three hashed inline scripts, the site's own files and one analytics origin are allowed, and both attack examples are blocked. Two attacks, two policies 'unsafe-inline' https: injected <script>alert(1)</script> runs <script src="https://evil.example"> runs theme bootstrap runs the policy passes nearly everything 'self' + 3 hashes + 1 origin injected <script>alert(1)</script> blocked <script src="https://evil.example"> blocked theme bootstrap (hash listed) runs only known content executes
The legitimate inline script behaves identically; only the attacker's scripts change outcome.

Maintaining It Over Time

The post-build step makes the policy self-maintaining for edits: change the theme bootstrap and the next build lists its new hash. Two events still need attention. An Astro upgrade changes the island bootstrap's content, so the hash changes — harmless, because the script regenerates it, but worth noting in the upgrade pull request so reviewers do not mistake it for tampering. A new integration may add an inline script; the build log line [csp] N inline script hash(es) makes that visible, and a CI assertion that N matches an expected value turns it into a deliberate decision rather than an accident. On this site the expected count lives in a one-line file next to the script, and changing it requires a review from the security owner.

Pitfalls & Rollback

  • Hashing source files instead of output. Minification and integrations change the bytes. Hash the final HTML.
  • Trimming whitespace before hashing. The browser hashes the exact content including leading newlines. Hash it byte for byte.
  • Per-page values in inline scripts. They produce one hash per page. Move values into data attributes.
  • Forgetting 'unsafe-inline' fallbacks. When hashes are present, modern browsers ignore 'unsafe-inline' anyway; remove it so older tooling does not flag the policy.
  • Rollback: switch the header back to report-only, or restore the previous _headers template. The post-build script can stay; it only fills a placeholder.

Conclusion

Static output is what makes a hash-based CSP practical: every inline script is known when the build finishes, so a one-second post-build step can hash them all and write the policy. On a 712-page Astro site, that replaced 'unsafe-inline' https: with three hashes and one origin, blocked the two classic injection patterns, and has needed no manual policy edits since — only a glance at the hash count when Astro or an integration changes.

FAQ

Why use hashes instead of nonces on a static site?

Nonces must be random per response, which requires a server to generate them. A static file served from a CDN is identical for every reader, so a nonce would be the same each time and provide no protection. Hashes describe the script content itself and work perfectly with static files.

Does Astro generate CSP hashes itself?

Recent Astro versions include an experimental CSP option that hashes scripts and styles it controls and emits a meta tag. A post-build script that hashes the final HTML covers everything, including inline scripts added by integrations, and can write an HTTP header instead.

What about inline event handlers like onclick?

Hashes do not cover inline event handler attributes unless you add 'unsafe-hashes', which is weaker. Move handlers into scripts with addEventListener; on a static site this is usually a small refactor.

Do I need a different policy per page?

Only if pages have different inline scripts. Most Astro sites have the same few inline scripts on every page, so one site-wide list of hashes works. If a page has unique inline scripts, a per-path header rule keeps the site-wide policy tight.