Dev.to Security πŸ” Cybersecurity πŸ‘ 0 πŸ“– 5 min read

Strict CSP for Astro SSR: Per-Request Nonces and Vite Virtual Modules (astro-strict-csp v0.3.0)

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 Co

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.

1. The Core Engineering Challenge: SSG vs. SSR

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-Policy HTTP header on the outgoing response.

2. Dual-Mode Architecture with Zero Manual Setup

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 into astro:build:done and 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.

3. Bridging Config to the Edge via Vite Virtual Modules

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:

  1. Build-Time Serialization: The integration registers virtual:astro-strict-csp/config and serializes the user's configuration object directly into the module graph at build time.
  2. Lazy Runtime Import: When the SSR middleware runs at the edge, it imports virtual:astro-strict-csp/config just 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)};`;
      }
    }
  }]
}

4. The Per-Request Nonce Lifecycle (And Excluding CMS Routes)

In SSR mode, every incoming request flows through a four-stage lifecycle inside the middleware:

  1. 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.
  2. Context Injection: The nonce is attached to context.locals.nonce, making it immediately available inside any .astro layout or component via Astro.locals.nonce.
  3. Response Interception: Once Astro renders the page, the middleware inspects the outgoing Response. If the Content-Type header is text/html, it constructs the policy string and attaches the Content-Security-Policy HTTP header:
   Content-Security-Policy: default-src 'none'; script-src 'self' 'nonce-b8a7c2...'; style-src 'self';
  1. 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.0 supports route exclusion rules (via string prefixes or RegExp patterns like /_emdash or /api/*) so admin interfaces and webhooks bypass the frontend CSP middleware cleanly.

5. Critical Security Gotcha: Inline Event Handlers Under Nonce CSP

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>

Upgrading to v0.3.0

astro-strict-csp v0.3.0 is live on npm now, still with zero external dependencies.

npm install astro-strict-csp@latest

If 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.

πŸ“° Read the original article on Dev.to Security

Originally published by Dev.to Security. Aggregated on AIWithGhost for educational purposes β€” full credit and traffic to the original publisher.