When I originally built astro-strict-csp and shipped v0.2.0, my website was a pure static Astro build (output: "static"). Every HTML file existed on disk at the end of astro build, which meant I could enforce a strict Content Security Policy by scraping inline scripts and styles into external files and hashing them with Subresource Integrity (SRI) before deploying.
Then I migrated ezinner.com to EmDash CMS on Cloudflare Workers and D1.
Moving to a live edge CMS meant flipping Astro from Static Site Generation (SSG) to Server-Side Rendering (output: "server"). The moment I made that switch, my existing CSP pipeline hit an obvious wall: in SSR mode, there are no static HTML files at build time to scrape. Pages are rendered on the fly inside a Cloudflare Worker isolate for every incoming request.
To keep my strict CSP posture (blocking 'unsafe-inline' completely) on an SSR Astro site without copy-pasting custom middleware across projects, I updated astro-strict-csp and shipped v0.3.0 with automatic dual-mode SSG and SSR support.
Here is how strict CSP differs between static and server-rendered Astro apps, how v0.3.0 bridges configuration into edge bundles via Vite virtual modules, and the W3C spec gotcha you need to watch out for with inline event handlers.
Enforcing a strict Content Security Policy (eliminating 'unsafe-inline') requires two completely different architectures depending on how your site is rendered:
- Static Sites (SSG): Have no runtime server to generate dynamic tokens per request. To avoid
'unsafe-inline', you need post-build static extraction—scraping inline<style>and<script>tags out of the generated HTML into external assets and locking them down with Subresource Integrity hashes (integrity="sha384-..."). - Server-Side Rendering (SSR): Dynamically renders HTML per request. Because the markup is assembled at runtime, you need an edge/server middleware that generates a fresh cryptographic nonce for every HTTP request, passes that nonce into your Astro component templates, and injects the matching
Content-Security-PolicyHTTP header on the outgoing response.
I wanted astro-strict-csp to work seamlessly whether a project is static or server-rendered, without forcing developers to manually create and wire up a src/middleware.ts file.
During the astro:config:setup hook, astro-strict-csp inspects Astro's output configuration and branches automatically:
- If
output: "static": It hooks intoastro:build:doneand runs the in-memory static extraction and SHA-384 SRI pipeline. - If
output: "server": It automatically registers a runtime middleware entrypoint using Astro's integration API:
// Inside astro:config:setup when output === 'server'
addMiddleware({
entrypoint: 'astro-strict-csp/middleware',
order: 'pre',
});By registering with order: 'pre', the nonce is generated and attached to the request context before any downstream user middleware or page rendering executes.
One of the trickiest parts of building an Astro SSR integration is passing user options defined in astro.config.mjs—like custom CSP directives or excluded route patterns—into the runtime middleware bundle.
When your SSR app compiles down to a Cloudflare Worker or edge function, there is no local filesystem to read astro.config.mjs from at runtime, and polluting globalThis is brittle across isolate boundaries.
To solve this cleanly, astro-strict-csp injects a Vite virtual module plugin during astro:config:setup:
- Build-Time Serialization: The integration registers
virtual:astro-strict-csp/configand serializes the user's configuration object directly into the module graph at build time. - Lazy Runtime Import: When the SSR middleware runs at the edge, it imports
virtual:astro-strict-csp/configjust like any bundled ES module—zero filesystem reads, zero runtime parsing overhead, and full compatibility with Cloudflare Workers, Node, and Bun.
// Virtual module generated at build time
const virtualModuleId = 'virtual:astro-strict-csp/config';
const resolvedVirtualModuleId = '\0' + virtualModuleId;
vite: {
plugins: [{
name: 'astro-strict-csp-config',
resolveId(id) {
if (id === virtualModuleId) return resolvedVirtualModuleId;
},
load(id) {
if (id === resolvedVirtualModuleId) {
return `export default ${JSON.stringify(options)};`;
}
}
}]
}In SSR mode, every incoming request flows through a four-stage lifecycle inside the middleware:
- Generation: As soon as a request arrives, the middleware generates a unique cryptographic nonce using the standard Web Crypto API (
crypto.randomUUID()), which runs natively in V8 isolates and modern server runtimes with zero dependencies. - Context Injection: The nonce is attached to
context.locals.nonce, making it immediately available inside any.astrolayout or component viaAstro.locals.nonce. - Response Interception: Once Astro renders the page, the middleware inspects the outgoing
Response. If theContent-Typeheader istext/html, it constructs the policy string and attaches theContent-Security-PolicyHTTP header:
Content-Security-Policy: default-src 'none'; script-src 'self' 'nonce-b8a7c2...'; style-src 'self';- Selective Route Bypass: When you run an embedded headless CMS like EmDash alongside your frontend, you don't want your strict frontend policy breaking the CMS admin dashboard or API routes. Version
0.3.0supports route exclusion rules (via string prefixes orRegExppatterns like/_emdashor/api/*) so admin interfaces and webhooks bypass the frontend CSP middleware cleanly.
If you read my v0.2.0 post on compiling inline event handlers, you might wonder how onclick attributes behave when you switch to SSR nonces.
Here is the trap that catches almost everyone moving to nonce-based CSP for the first time:
- By W3C CSP Level 2 and Level 3 specifications, cryptographic nonces only apply to
<script>and<style>tags—never to inline HTML event attributes (onclick,onload,onsubmit). - Even if your page has a valid nonce header, writing
<button onclick="window.print()">in SSR mode will still trigger an'unsafe-inline'violation and get blocked by the browser.
In SSR mode, the clean architectural pattern is delegating your event listeners inside a nonced <script> block using Astro.locals.nonce:
<button id="print-btn">Print Article</button>
<script is:inline nonce={Astro.locals.nonce}>
document.getElementById('print-btn')?.addEventListener('click', () => {
window.print();
});
</script>astro-strict-csp v0.3.0 is live on npm now, still with zero external dependencies.
npm install astro-strict-csp@latestIf you are running Astro in SSR mode on Cloudflare Workers, Node, or Bun—or combining it with EmDash—give v0.3.0 a spin and let me know in the GitHub Issues if you run into any edge cases.