p4ni.

Tutorial

A Strict CSP for Astro on Cloudflare Workers (No Nonce Required)

· 14 min read

On this page

This site shipped with no security headers at all. Not a weak Content Security Policy — none. curl -I came back with content-type, some Cloudflare cache metadata, and nothing else. It’s the kind of thing that stays invisible until you look, because nothing about the site is broken without it.

Adding a CSP to a static site is where most guides stop being useful. They assume you have a server that can generate a fresh nonce per request. I don’t. Neither do you, if you’re deploying prerendered HTML to Cloudflare Workers static assets. What follows is the policy I actually shipped, the script that generates it, and the two places my first instinct was wrong: how many hashes the site needs, and how to tell whether the header is live.

Why nonces are off the table

The 'nonce-...' approach works like this: the server picks a random value per response, stamps it on every legitimate <script> tag, and lists it in the CSP header. An injected script can’t guess the value, so it doesn’t run.

Every word of that requires a server in the request path. My dist/ is uploaded to Cloudflare’s edge and served directly as files. There is no code running per request to stamp anything — that’s the entire point of static assets, and why they’re free and unmetered. A nonce baked in at build time is a constant, and a constant nonce is worse than no nonce: it’s a permanent allowlist entry that any injected markup can copy.

So: hashes. You compute the SHA-256 of each inline script’s contents and list those in script-src. The browser hashes what it finds and compares. No server needed.

The usual objection is that you can put a Worker in front and inject nonces per request with HTMLRewriter. I built that later to check, and it stamps injected scripts along with legitimate ones — worth reading before you take that route.

The catch is that hashes are exact: change one character of an inline script — even a comment — and the browser blocks it. That makes a hand-maintained hash list a liability, which is why the second half of this post generates them instead.

What’s actually inline

Before writing a policy, I counted. This walks the build output and hashes every inline <script>:

// scan.mjs — run after `pnpm build`
// The regex below is fine for Astro's own output; an attribute value
// containing ">" would break it, so don't point this at arbitrary HTML.
import fs from 'node:fs';
import path from 'node:path';
import crypto from 'node:crypto';

const files = [];
(function walk(dir) {
  for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
    const full = path.join(dir, entry.name);
    if (entry.isDirectory()) walk(full);
    else if (entry.name.endsWith('.html')) files.push(full);
  }
})('dist');

const blocks = new Map();
for (const file of files) {
  const html = fs.readFileSync(file, 'utf8');
  for (const [, attrs, body] of html.matchAll(/<script([^>]*)>([\s\S]*?)<\/script>/gi)) {
    if (/\ssrc\s*=/i.test(attrs) || !body.trim()) continue;
    const hash = crypto.createHash('sha256').update(body).digest('base64');
    const type = attrs.match(/\stype\s*=\s*["']([^"']+)["']/i)?.[1] ?? 'classic';
    blocks.set(hash, { type, pages: (blocks.get(hash)?.pages ?? 0) + 1 });
  }
}

console.log(`${files.length} pages, ${blocks.size} unique inline blocks`);
for (const type of new Set([...blocks.values()].map((b) => b.type))) {
  const group = [...blocks.values()].filter((b) => b.type === type);
  const total = group.reduce((n, b) => n + b.pages, 0);
  console.log(`  ${type}: ${group.length} unique across ${total} occurrences`);
}

The result on this blog, at the time of writing:

35 pages, 37 unique inline blocks
  classic: 2 unique across 70 occurrences
  module: 1 unique across 35 occurrences
  application/ld+json: 34 unique across 34 occurrences

37 hashes across 35 pages is a bad number. It means the policy grows with the site — every new post adds another entry, and the header gets a little longer forever. That’s the shape of a policy nobody maintains.

The type column already hints at the way out. Here’s what those blocks actually are:

Inline blocktypeUnique hashesWhere
GA4 gtag config(none)1all 35 pages
Theme-before-paint(none)1all 35 pages
Theme toggle handlermodule1all 35 pages
Structured dataapplication/ld+json3434 pages — every one but the 404

Three of them are code. The other 34 are the JSON-LD I emit per page, and the browser treats those two categories differently enough to change the whole policy.

The thing most CSP guides get wrong

HTML’s “prepare the script element” algorithm resolves a <script> element’s type to one of four things — classic, module, importmap, speculationrules — and returns immediately if it resolves to none of them. That last case is a data block: the browser never runs it, and something else reads it out of the DOM later. application/ld+json and plain application/json land here.

The early return happens before the algorithm reaches its CSP check, so script-src never sees a data block — no hash needed, no violation reported.

What matters is where that line falls, and it is not where “is this a JavaScript MIME type?” would put it. module isn’t a MIME type and runs anyway; so do importmap and speculationrules, which is why there’s a dedicated 'inline-speculation-rules' source expression for allowing inline speculation rules without opening up 'unsafe-inline'. A directive keyword exists for them precisely because script-src applies.

There’s also a way to ship rules that never touches a <script> element, and that one leaves script-src out of it. Responses from this site carry a speculation-rules: "/cdn-cgi/speculation" header, added by Cloudflare’s Speed Brain. The ruleset itself is JSON at another URL, so there’s no element to hash and no violation to report; the prefetches it triggers are governed by default-src instead. Same feature, different directive depending on whether you inline it or hand it over in a header.

Reading the algorithm is one thing; I wanted it confirmed against a live policy that already blocked everything else. Load a page under the real CSP and run this from the DevTools console. Console evaluation is itself exempt from CSP, but a <script> element you append to the document is not — that’s what’s being tested here:

const violations = [];
document.addEventListener('securitypolicyviolation', (e) =>
  violations.push(`${e.violatedDirective} <- ${e.blockedURI}`)
);

const inject = (props) =>
  document.head.appendChild(Object.assign(document.createElement('script'), props));

inject({ textContent: 'window.__pwned = true' });               // 1. inline, no hash
inject({ src: 'https://evil.example.com/x.js' });               // 2. off-allowlist origin
inject({ type: 'application/ld+json', textContent: '{}' });     // 3. data block
inject({ type: 'importmap', textContent: '{"imports":{}}' });   // 4. resolves to a real type
inject({ type: 'speculationrules', textContent: '{}' });        // 5. same

await new Promise((r) => setTimeout(r, 400));
({ pwned: window.__pwned === true, violations });

The verdict:

{
  "pwned": false,
  "violations": [
    "script-src-elem <- inline",
    "script-src-elem <- https://evil.example.com/x.js",
    "script-src-elem <- inline",
    "script-src-elem <- inline"
  ]
}

Four violations out of five injections. The inline script didn’t run and the off-allowlist origin was refused, which confirms the policy is enforced. Only the ld+json block passed without a sound. The import map and the speculation rules were blocked exactly like ordinary inline code — so if you ever ship either of them inline, they need hashes too.

One thing to know before you read your own violations: the report says script-src-elem, which is nowhere in the policy. Chrome names the effective directive, and script-src-elem falls back to script-src when it isn’t set. Firefox reports script-src for the same violation. Don’t go looking for a directive you never wrote.

So the policy needs 3 hashes, not 37, and it doesn’t grow when I publish a post. Hashing the structured data too would take the header from 587 characters to 2,423 — four times the size, rewritten on every build, protecting you from exactly nothing extra.

Generating the header at build time

Hashes go stale the moment you edit an inline script, so they shouldn’t be written by hand. Cloudflare Workers reads a plain-text _headers file from the assets directory, which makes this an Astro integration that runs after the build:

// src/integrations/security-headers.mjs
import fs from 'node:fs';
import path from 'node:path';
import crypto from 'node:crypto';
import { fileURLToPath } from 'node:url';

/**
 * Script types the browser refuses to run at all. Note what's absent:
 * importmap and speculationrules resolve to real script types, so an inline
 * one still needs its hash in script-src.
 */
const DATA_BLOCK_TYPES = new Set(['application/ld+json', 'application/json']);

const sha256 = (body) =>
  `'sha256-${crypto.createHash('sha256').update(body, 'utf8').digest('base64')}'`;

function htmlFiles(dir) {
  return fs.readdirSync(dir, { withFileTypes: true }).flatMap((entry) => {
    const full = path.join(dir, entry.name);
    if (entry.isDirectory()) return htmlFiles(full);
    return entry.name.endsWith('.html') ? [full] : [];
  });
}

function inlineScriptHashes(root) {
  const hashes = new Set();
  for (const file of htmlFiles(root)) {
    const html = fs.readFileSync(file, 'utf8');
    for (const [, attrs, body] of html.matchAll(/<script([^>]*)>([\s\S]*?)<\/script>/gi)) {
      if (/\ssrc\s*=/i.test(attrs)) continue; // external — covered by the origin allowlist
      const type = attrs.match(/\stype\s*=\s*["']([^"']+)["']/i)?.[1].toLowerCase() ?? '';
      if (DATA_BLOCK_TYPES.has(type) || !body.trim()) continue;
      hashes.add(sha256(body));
    }
  }
  return [...hashes].sort();
}

export default function securityHeaders() {
  return {
    name: 'security-headers',
    hooks: {
      'astro:build:done': ({ dir, logger }) => {
        const root = fileURLToPath(dir); // not new URL(dir).pathname — that breaks on Windows
        const hashes = inlineScriptHashes(root);
        const csp = [
          `default-src 'self'`,
          `script-src 'self' ${hashes.join(' ')} https://www.googletagmanager.com`,
          `connect-src 'self' https://*.google-analytics.com https://*.analytics.google.com https://*.googletagmanager.com`,
          `img-src 'self' data: https://*.google-analytics.com https://*.googletagmanager.com`,
          `style-src 'self' 'unsafe-inline'`,
          `font-src 'self'`,
          `object-src 'none'`,
          `base-uri 'self'`,
          `form-action 'self'`,
          `frame-ancestors 'none'`,
          `upgrade-insecure-requests`,
        ].join('; ');

        fs.writeFileSync(
          path.join(root, '_headers'),
          [
            '/*',
            `  Content-Security-Policy: ${csp}`,
            '  Strict-Transport-Security: max-age=31536000; includeSubDomains; preload',
            '  X-Content-Type-Options: nosniff',
            '  Referrer-Policy: strict-origin-when-cross-origin',
            '  Cross-Origin-Opener-Policy: same-origin',
            '  Permissions-Policy: camera=(), microphone=(), geolocation=(), payment=(), usb=()',
            '',
          ].join('\n')
        );
        logger.info(`_headers written with ${hashes.length} inline script hash(es)`);
      },
    },
  };
}

Register it last, so it reads HTML the other integrations have finished emitting:

// astro.config.mjs
import securityHeaders from './src/integrations/security-headers.mjs';

export default defineConfig({
  integrations: [mdx(), sitemap({ /* ... */ }), securityHeaders()],
});

Now the build tells you what it did:

[security-headers] _headers written with 3 inline script hash(es)

If that number ever changes, something added or removed an inline script — which is exactly the moment you want to be told.

This paid for itself the same day. While this post was still a draft, an unrelated change to the theme-toggle handler shifted one of the three hashes. I didn’t touch the CSP; the next build just emitted a different header and the toggle kept working. A hand-maintained list would have failed silently here, and the theme toggle is the worst possible script to lose that way — it only misbehaves after a reader clicks it, so you’d hear about it from someone else or not at all.

Before you turn it on

Ship the header as Content-Security-Policy-Report-Only first. Same value, same file, one word longer:

`  Content-Security-Policy-Report-Only: ${csp}`,

The browser evaluates the policy, reports what it would have blocked, and blocks nothing. Leave it for a day, watch the console on a few pages, then drop the suffix. That’s the cheap version; the thorough version adds a report-to endpoint so violations reach you instead of your readers’ devtools. I don’t run one here, so the quiet since launch is unconfirmed rather than clean — a gap I’d rather name than count as a win.

One thing hashes will never cover, in either mode: script-src hashes apply to <script> elements, not to inline event handler attributes or javascript: URLs. A single onclick="…" left in a template stays blocked no matter how many hashes you add, and allowing it takes 'unsafe-hashes' plus a hash per handler — more upkeep than moving the handler into a script. Grep your templates for on*= attributes before you enforce, not after.

What I couldn’t lock down

style-src still carries 'unsafe-inline', and that’s not laziness. Two earlier decisions made it unavoidable:

  • build.inlineStylesheets: 'always' ships the stylesheet inside every page. That removed a render-blocking round trip, as part of a change set that moved the score from 83 to 99.
  • The post list staggers its entrance animation with a style="animation-delay" computed from each item’s index, and style attributes need 'unsafe-inline' unless you move every one of them into a class.

I could hash the inline <style> blocks, but not the attributes. Dropping either optimization to tighten a directive that mostly guards against CSS-based data exfiltration isn’t a trade I’d make on a blog. It’s worth naming the gap rather than pretending the policy is airtight: a strict script-src with a loose style-src is the honest description of most static sites.

Two deployment gotchas

A query string is not a cache buster. I deployed, ran curl -I, and got no security headers back at all. cf-cache-status: HIT was sitting right there in the response, so I did the obvious thing — appended ?x=$RANDOM — and the headers appeared. Case closed, I thought, and very nearly wrote it up that way.

That explanation doesn’t survive a second look. Ask for a URL that has never been requested in its life:

curl -sI "https://astro.p4ni.com/blog/?zzz=$RANDOM" | grep -i cf-cache-status
# cf-cache-status: HIT

I can’t tell you why a first-ever request comes back as a hit — either the query string is absent from the cache key, or static assets are being served out of the asset store and counted differently. Either way the conclusion holds: ?x=$RANDOM bypassed nothing. And today the bare URL returns the current policy on a HIT too, so “cached responses keep the headers they were cached with” doesn’t hold either.

Whatever fixed it between those two curl calls, it wasn’t the query string — most likely the deploy just hadn’t finished propagating. If your headers don’t show up right after a deploy, the options that actually do something are waiting and purging the cache from the dashboard. Watch cf-cache-status while you wait. I nearly spent an hour debugging a file that was working correctly, and then nearly published the wrong reason why.

_headers is not an asset. Cloudflare parses it and does not serve it, so it won’t leak your policy source as a fetchable file. Worth confirming, since it sits in the same directory as everything you are publishing:

curl -s -o /dev/null -w "%{http_code}\n" https://astro.p4ni.com/_headers
# 404

The shipped policy

/*
  Content-Security-Policy: default-src 'self'; script-src 'self' 'sha256-Tr6Y…' 'sha256-a1fF…' 'sha256-fMw+…' https://www.googletagmanager.com; connect-src 'self' https://*.google-analytics.com https://*.analytics.google.com https://*.googletagmanager.com; img-src 'self' data: https://*.google-analytics.com https://*.googletagmanager.com; style-src 'self' 'unsafe-inline'; font-src 'self'; object-src 'none'; base-uri 'self'; form-action 'self'; frame-ancestors 'none'; upgrade-insecure-requests
  Strict-Transport-Security: max-age=31536000; includeSubDomains; preload
  X-Content-Type-Options: nosniff
  Referrer-Policy: strict-origin-when-cross-origin
  Cross-Origin-Opener-Policy: same-origin
  Permissions-Policy: camera=(), microphone=(), geolocation=(), payment=(), usb=()

The hashes come out sorted, so the header stays byte-identical between builds even when the scripts move around in the HTML — as long as their contents don’t change.

Analytics is the only third party here, and it needs three separate directives: script-src to load gtag.js, connect-src for the collection beacons, and img-src for the pixel fallback. Allowlist only the first and measurement fails quietly — which is the failure mode to watch for, because nothing on the page looks broken.

Every third party you add means redoing that arithmetic. Ads are going at the bottom of posts here, which will mean https://pagead2.googlesyndication.com in script-src, img-src for the creatives, and a frame-src for the ad iframes — absent today only because default-src 'self' is dropping all of it. That’s the value of writing the fallback: a missed directive shows up as “no errors, nothing rendered” rather than as nothing at all.

The 'self' in script-src is still removable, for that matter. The only external script in this build is gtag.js; everything of mine is inline, and _astro/ holds nothing but fonts. So 'self' is permitting same-origin script files that don’t exist. Dropping it also closes the classic detour of pointing <script src> at a same-origin endpoint that returns JSON. If external scripts were coming, 'strict-dynamic' would be the move instead — but that works by letting a hash-approved script vouch for the ones it injects, which buys nothing when three inline hashes cover the whole site.

The rest of the block isn’t CSP and doesn’t need much defending. nosniff stops MIME sniffing, Referrer-Policy trims the referrer on cross-origin requests, Cross-Origin-Opener-Policy cuts the window.opener link, and Permissions-Policy switches off device APIs a blog has no use for. One line each, and none of them has broken anything here.

Strict-Transport-Security does need a caveat. preload is a declaration of intent, not an enrollment — submitting to the preload list is a separate manual step at hstspreload.org, and that form only accepts a base domain. astro.p4ni.com can’t be submitted on its own. Preloading this subdomain would mean serving the header from p4ni.com with includeSubDomains and enrolling every other subdomain along with it, and removal takes months to reach users. So the token sits there inert, which I’d rather say out loud than leave looking like a finished job.

Recap

  • Static assets have no request-time hook, so 'nonce-...' isn’t available. Hashes are the only real option, and a build-time constant nonce is strictly worse than none.
  • application/ld+json and application/json are data blocks: never executed, never checked against script-src. On this site that’s the difference between a policy fixed at 3 hashes and one that grows by one with every post. module, importmap and speculationrules are not data blocks and do need their hashes — though speculation rules also have 'inline-speculation-rules' as an option.
  • Generate the hashes in astro:build:done rather than by hand. The count in the build log is a free canary for inline scripts appearing or disappearing.
  • Roll it out as -Report-Only first, then verify with a securitypolicyviolation listener and a script you expect to be blocked. A policy that reports nothing might be strict, or might not be applying at all.
  • Don’t trust a query string to bust Cloudflare’s cache. Watch cf-cache-status, and purge when you need certainty.