A Strict CSP Is Easy When Your Site Is Static
Cross-site scripting has sat at or near the top of every web-vulnerability list for two decades. The defense that actually contains it — not “sanitize harder,” but a real architectural backstop — is a Content-Security-Policy strict enough that an injected <script> simply never runs. Almost nobody ships one.
The reason isn’t ignorance. On a typical site, a strict CSP is a months-long war of attrition. A tag manager injects inline scripts you don’t control. A CMS plugin writes onclick="" handlers straight into the markup. An A/B-testing snippet pulls code from a third party and evals it. Every one of those is a hole you either close or carve an exception for, and the exceptions pile up until your “policy” permits exactly what it was meant to forbid.
Static sites don’t have that problem. You built every script that ships. There’s no plugin soup, no runtime injecting markup behind your back, no tag manager bolted on by the marketing team. The JavaScript surface is small, known, and frozen at build time — which is precisely the condition a strict CSP needs. The hard part of CSP was never the header. It was controlling what runs on your page. Static architecture already did that part.
Here’s the policy this site actually ships, and how each piece earned its place.
What a strict CSP actually buys you
A CSP is a single HTTP response header that tells the browser, in fine detail, which sources of script, style, image, font, and connection it’s allowed to honor — and to refuse everything else. The browser enforces it. You don’t write any application code.
Its value is that it’s a backstop after your other defenses fail. Suppose an attacker slips a <script> into a comment, a URL parameter you reflected, a markdown field you didn’t escape perfectly. Without CSP, that script runs with the full authority of your origin: it can read cookies, rewrite the DOM, and POST your users’ session data to an attacker’s server. With a strict CSP, the browser looks at the injected script, finds it isn’t on the allowlist, and declines to execute it. The injection becomes inert text.
That’s defense in depth. Your escaping is the first wall; the CSP is the wall behind it. You want both, because the first one will eventually have a gap.
And here’s the part the security industry would rather you didn’t dwell on: the highest-leverage piece of this is a response header you write once. The “managed WAF” dashboards and “XSS protection” SaaS upsells are mostly an attempt to sell you a wrapper around controls the platform already gives you for free — the same pattern as every other box you can already check yourself. A header isn’t a complete security program. But the strict CSP at the center of it costs nothing and runs on every request.
Static sites are the easy case
The thing that makes a strict CSP tractable is total knowledge of your script surface. You can only allowlist what you can enumerate.
On a static site, enumeration is trivial because you are the only thing that put scripts on the page. Run the build, look at the output, and there’s a finite, inspectable set of <script> tags. Nothing appears at runtime that wasn’t there at build time. Compare that to a WordPress install where six plugins, a theme, and Google Tag Manager each inject their own inline scripts on every page load — you can’t allowlist a set you can’t predict, and a CSP loose enough to permit all of it isn’t protecting anything.
This is the same dividend static architecture pays everywhere else. Deployments get simpler because there’s no running server to coordinate. Security gets simpler because there’s no running server — and no plugin runtime — generating unpredictable markup. The fewer moving parts emit script, the tighter you can draw the policy. Shipping fewer dependencies isn’t just a performance story; every package you don’t ship is one fewer source of script you have to reason about in your CSP.
A header, not a <meta> tag
The first thing people reach for is <meta http-equiv="Content-Security-Policy">, because it lives in your HTML and a static site is “just HTML.” Don’t. The meta-tag form is a strictly weaker subset of the real thing. It can’t use frame-ancestors (your clickjacking defense), it can’t be delivered in report-only mode, and it can’t set sandbox. It also takes effect only once the parser reaches it, so anything above it in the document is unprotected.
A real CSP is an HTTP response header. “But a static site has no server to set headers” — it has a host, and the host sets headers. That’s the bridge. Whatever serves your dist/ folder — Render, Netlify, Cloudflare, S3 behind CloudFront — has a config surface for response headers. On Render it’s a few lines in render.yaml:
headers:
- path: /*
name: Content-Security-Policy
value: "default-src 'self'; script-src 'self' 'wasm-unsafe-eval' 'sha256-RXkcfoSftBhqLqNk4M0VFUiGD7kCWxZnh+Oq41DZUM8=' https://cloud.umami.is; worker-src 'self' blob:; style-src 'self' 'unsafe-inline'; img-src 'self' data: blob:; font-src 'self'; connect-src 'self' https://cloud.umami.is https://api-gateway.umami.dev; frame-src 'none'; frame-ancestors 'none'; form-action 'self'; base-uri 'self'; upgrade-insecure-requests"
It’s one long header value. Let’s pull it apart.
The policy, directive by directive
Formatted so it’s readable, here’s the whole thing:
default-src 'self';
script-src 'self' 'wasm-unsafe-eval'
'sha256-RXkcfoSftBhqLqNk4M0VFUiGD7kCWxZnh+Oq41DZUM8='
https://cloud.umami.is;
worker-src 'self' blob:;
style-src 'self' 'unsafe-inline';
img-src 'self' data: blob:;
font-src 'self';
connect-src 'self' https://cloud.umami.is https://api-gateway.umami.dev;
frame-src 'none';
frame-ancestors 'none';
form-action 'self';
base-uri 'self';
upgrade-insecure-requests
default-src 'self' is the floor. It’s the fallback for every *-src directive you don’t name explicitly, so anything you forget defaults to “same origin only” instead of “anything goes.” You start from a closed door and open specific ones on purpose.
script-src 'self' ... is the directive that matters most, and the one a strict CSP lives or dies on. Note what’s not there: no 'unsafe-inline', no 'unsafe-eval'. Those two tokens are how most policies quietly surrender — 'unsafe-inline' permits any inline <script>, which is exactly the XSS payload you’re defending against. This policy allows scripts from only three places:
'self'— bundled scripts served from our own origin.- One
'sha256-...'hash — a single inline script, allowlisted by the hash of its contents. https://cloud.umami.is— the one third-party script we load on purpose.
That 'self' covers our bundled JavaScript only because of a deliberate build choice. Astro hoists component scripts, and by default it may inline small ones directly into the HTML — which a strict policy would then block. One line in the Astro config forces every hoisted script out to an external /_astro/*.js file instead:
// astro.config.mjs
vite: {
// Externalize hoisted scripts so 'self' covers them
// instead of needing 'unsafe-inline'.
build: { assetsInlineLimit: 0 },
}
Now an external file is just another same-origin resource, and 'self' waves it through. The build does the work so the policy can stay strict.
'wasm-unsafe-eval' is there because the Pagefind search index is a WebAssembly module, and instantiating WASM counts as eval under CSP unless you allow it. 'wasm-unsafe-eval' is the narrow permission for only WebAssembly compilation — far safer than the blanket 'unsafe-eval', which would also re-enable eval() and new Function() for an attacker. Pagefind also runs its search in a Web Worker spun up from a blob: URL, which is why worker-src 'self' blob: exists as its own directive rather than leaking blob: into script-src.
style-src 'self' 'unsafe-inline' is the honest compromise in this policy, and I’m not going to pretend otherwise. Two things emit inline styles we can’t easily avoid: Shiki syntax highlighting, which colors every token with an inline style="color:…", and Astro’s scoped-style attributes. CSP3 added 'unsafe-hashes' for hashing inline style attributes, but Shiki produces thousands of unique ones — hashing them all is unworkable. So style-src keeps 'unsafe-inline'. The risk tradeoff is acceptable: inline style can enable some data exfiltration and UI redressing, but it can’t execute code the way inline script can. Lock down script-src like your origin depends on it; be pragmatic about style-src.
The rest are quick but load-bearing:
img-src 'self' data: blob:— own images, plusdata:/blob:for inlined and generated graphics.font-src 'self'— fonts come from our origin and nowhere else, which is only possible because we stopped loading Google Fonts and self-host them. Every third party you cut is a directive you get to tighten.connect-src— restricts wherefetch/XHR/WebSocket can go. The only allowances are the Umami analytics endpoints. An injected script can’t beacon stolen data to an attacker’s domain, because the browser refuses the connection.frame-src 'none'andframe-ancestors 'none'— we embed no iframes, and nobody may embed us. The second is your real clickjacking defense and the modern replacement forX-Frame-Options.form-action 'self'— forms can only POST back to us, so an injection can’t repoint a login form at an attacker.base-uri 'self'— blocks an injected<base>tag from rewriting every relative URL on the page.upgrade-insecure-requests— any strayhttp://reference gets upgraded tohttps://instead of triggering mixed-content errors.
One thing worth adding that isn’t here: object-src 'none'. It’s covered by default-src 'self', but explicitly killing <object>/<embed> is cheap belt-and-suspenders and a standard part of Google’s strict-CSP recipe.
Nonces need a server. Static sites use hashes.
There are two ways to allowlist a specific inline script: a nonce or a hash. For a static site the choice is made for you, and understanding why is the whole game.
A nonce is a random token you generate fresh for every HTTP response, drop into both the CSP header and the nonce="" attribute of each script you bless, and never reuse. It only works if something generates a new random value per request — which means a server running at request time. A static site has no such thing. The HTML is baked once at build and served byte-for-byte identical to everyone. A “nonce” baked at build time is a constant: it ships in your HTML, anyone can read it, and an attacker just copies it onto their injected script. A static nonce is security theater.
A hash has no such problem. You compute the SHA-256 of a script’s exact contents at build time and put that digest in the policy. The browser hashes each inline script it encounters and runs it only if the digest matches. The hash is public — that’s fine, because a hash isn’t a secret. An attacker can’t forge a script that produces the same SHA-256 as your blessed one. Hashes are content-addressed trust, and content that’s fixed at build time is exactly what a static site has.
That’s the 'sha256-...' token in script-src. It allowlists precisely one inline script — the no-flash theme initializer that has to run before first paint to avoid a flash of the wrong color scheme:
<script is:inline>
;(function () {
var pref = localStorage.getItem('ss-theme')
var theme =
pref === 'light' || pref === 'dark'
? pref
: matchMedia('(prefers-color-scheme: dark)').matches
? 'dark'
: 'light'
document.documentElement.setAttribute('data-theme', theme)
})()
</script>
This is the one script that genuinely must be inline — externalizing it would reintroduce the flash it exists to prevent. So it stays inline and earns a hash. The is:inline directive is Astro’s way of saying “leave this one alone, don’t hoist it,” which is also what makes its content stable enough to hash.
(If you’re on a host with edge compute — Cloudflare Workers, Netlify Edge — you can inject a real per-request nonce and skip hashes. But for a genuinely static host, hashes are the native mechanism. And for a site with a tiny, known set of scripts, an explicit allowlist is arguably stricter than the nonce-plus-'strict-dynamic' pattern aimed at big apps that load script graphs dynamically.)
Generating and maintaining the hash
You don’t have to compute the digest by hand — the browser will tell you. Ship the policy without the hash, load the page, and the console prints a violation naming the exact hash it expected:
Refused to execute inline script because it violates the following
Content-Security-Policy directive: "script-src 'self'". Either the
'unsafe-inline' keyword, a hash ('sha256-RXkcfoSftBhqLqNk4M0VFU...'),
or a nonce is required to enable inline execution.
Copy that sha256-… value into script-src and reload. Done. If you’d rather compute it yourself, hash the exact bytes between the tags:
printf '%s' "$(cat theme-init.js)" | openssl dgst -sha256 -binary | openssl base64
The maintenance cost is the honest catch: the hash is tied to the script’s exact contents, so if you change one character of that inline script, you must regenerate the hash. Forget, and the script silently stops running in production. The defense is a one-line comment next to the directive reminding the next person (usually future you) to re-hash on edit — and the fact that there’s exactly one hashed script keeps the burden trivial. This is the upside of an inline-script surface you can count on one hand.
Roll it out without breaking the site
Don’t deploy a strict CSP straight to enforcing mode and hope. Use the report-only valve. A second header — Content-Security-Policy-Report-Only — makes the browser evaluate the policy and report what would have been blocked, while breaking nothing:
- path: /*
name: Content-Security-Policy-Report-Only
value: "default-src 'self'; script-src 'self' 'wasm-unsafe-eval' https://cloud.umami.is; ...; report-to csp-endpoint"
Run report-only across real traffic for a few days and watch what trips. Every violation is either a hole to plug (a script you forgot) or an exception to add (a hash you missed). When the reports go quiet, promote the same policy to the enforcing Content-Security-Policy header and drop the report-only one.
The honest caveat for static sites: report-to/report-uri need somewhere to send reports, and a static host has no endpoint of its own. You can point it at a cheap serverless function or a collector service — or, for a small site, skip the plumbing entirely and just watch the browser console on a staging deploy. The reports are the rigorous path; the console is the pragmatic one. Either beats flipping it on blind.
While you’re in the headers
A CSP travels with a small crew of one-line headers that close other gaps. Since you’re already editing the host config, set them too:
- { path: /*, name: Strict-Transport-Security, value: 'max-age=31536000; includeSubDomains; preload' }
- { path: /*, name: X-Content-Type-Options, value: 'nosniff' }
- { path: /*, name: Referrer-Policy, value: 'strict-origin-when-cross-origin' }
- { path: /*, name: Cross-Origin-Opener-Policy, value: 'same-origin' }
- { path: /*, name: Permissions-Policy, value: 'camera=(), microphone=(), geolocation=(), interest-cohort=()' }
Strict-Transport-Security with preload pins HTTPS. X-Content-Type-Options: nosniff stops MIME-sniffing a response into something executable. Referrer-Policy trims what you leak in the Referer header. Cross-Origin-Opener-Policy isolates your browsing context. And Permissions-Policy switches off APIs you never use — note interest-cohort=(), which opts the site out of cohort-based ad tracking on principle. None of these is a CSP, but together they’re the difference between “has a security header” and “is actually hardened.”
The takeaway
A strict CSP has a reputation for being hard, and on most of the web it is. But the difficulty was never in the policy syntax — it’s in not knowing what runs on your own pages. A site assembled from plugins and tag managers can’t answer that question, so it can’t write the policy.
A static site already answered it at build time. You know every script because you put it there. That makes the strict version — script-src with no 'unsafe-inline', no 'unsafe-eval', one hash for the one inline script that needs it — not a heroic effort but a few lines of host config. The hardest part of CSP is the part static architecture handed you for free.
Open render.yaml. Write the header. Roll it out report-only, then enforce. It’s the cheapest serious security win on the board, and your site is already in the rare position to actually claim it.