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
_headersfile (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-islanddefinitions) 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(){...
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">
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
| Measure | Before | After |
|---|---|---|
script-src | 'self' 'unsafe-inline' https: | 'self' + 3 hashes + 1 origin |
| Distinct inline scripts in build | 4 | 3 |
| Pages with a page-specific inline script | 38 | 0 |
| Post-build script run time | — | 1.4 s (712 pages) |
| Mozilla Observatory CSP test | fail (unsafe-inline) | pass |
| Hash-mismatch reports after enforcing (4 weeks) | — | 0 |
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
_headerstemplate. 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.
Related
- Parent: Security Headers and Hardening for Static Sites — the full hardening set.
- Writing a Content Security Policy for a Static Site — inventory and report-only rollout.
- Subresource Integrity for Third-Party Assets — hashing external scripts too.
- Astro Islands vs Full Hydration Performance — where the island bootstrap comes from.
- Loading Google Tag Manager Without Hurting INP — if a tag manager must stay.