A Content Security Policy (CSP) tells the browser what a page may load and run. Done well, it turns many injection attacks into a warning in the console instead of someone else’s script running on your site. Done poorly, it ends up with 'unsafe-inline' and 'unsafe-eval', which let through exactly what you wanted to stop.
This site runs with a strict CSP, with neither of them (DEC-019). Here’s what it took, because writing the policy was the easy part: the work was everything that broke it.
The policy: a meta tag with hashes
Astro 7 has a native CSP: it writes the policy as a <meta> tag on every page and adds the hashes of the scripts and styles it generates itself. My part lives in astro.config.ts and src/lib/csp.ts:
security: {
csp: {
algorithm: 'SHA-256',
// The Lab's APIs on Render: the API Playground and the data migrator's Python engine.
directives: cspDirectives({
connectTo: [process.env.PUBLIC_PLAYGROUND_API_URL, process.env.PUBLIC_PYLAB_API_URL],
analytics,
}),
scriptDirective: { resources: cspScriptResources({ analytics }), hashes: [cspHash(BOOT_SCRIPT)] },
styleDirective: { resources: CSP_STYLE_RESOURCES, hashes: [cspHash(VIEW_TRANSITION_STYLE)] },
},
},
cspDirectives builds the rest: default-src 'self', images from the site itself (and data:), fonts and media only from the site, object-src 'none', and base-uri and form-action limited to the site. External origins are only the ones needed: Cloudflare Turnstile (script and iframe), the Lab’s two APIs on Render (the API Playground and the data migrator’s Python engine) and, in production builds only, Cloudflare Web Analytics.
One limit of the <meta> tag: it can’t carry frame-ancestors. X-Frame-Options: DENY in public/_headers covers that case.
The boot script, allowed by its hash
Before painting, every page runs a short script that applies the theme and currency you saved. It’s an inline script, so the CSP would block it, unless the policy allows exactly that text. That’s why BOOT_SCRIPT is a plain string in src/lib/boot.ts: Base.astro writes it into the page and astro.config.ts computes its hash with cspHash. Both see the same bytes; if I edit the script, the hash changes on the next build, with nothing else to update.
It’s the first thing in <body>, not in <head>: that way the CSP <meta>, which Astro adds at the end of <head>, already covers it.
The only inline style follows the same idea: VIEW_TRANSITION_STYLE (src/lib/view-transition.ts) turns on the transitions between pages, and style-src allows it by its hash. This one does go in <head>: Chrome decides whether the new page takes part in the transition as soon as <body> starts, and when the rule lived in the stylesheet and that file hadn’t arrived yet, it skipped the transition with an error in the console.
Zod without eval
Here came the first surprise. Zod 4, the library I use to validate forms and API responses in the browser, uses new Function to validate faster. Without 'unsafe-eval', the CSP blocks it and reports a violation. Zod has a jitless mode that doesn’t use it, but it has to be on before any schema is built, and schemas are built as soon as @aim/contracts loads, inside the islands’ code.
The fix was the same boot script. Its first line:
globalThis.__zod_globalConfig = { ...globalThis.__zod_globalConfig, jitless: true }
Zod reads that shared object when it loads (it’s the same one z.config() returns), so by the time the first island arrives, it’s already in jitless mode. Since relying on an internal detail is fragile, boot.test.ts checks that config() returns that very object: if a Zod update renames it, the test fails before the CSP starts reporting eval.
No style=
The policy has no 'unsafe-inline' in style-src either, so a style="…" attribute in the HTML gets blocked. On a site with Preact islands this stings more than you’d think, because the natural move is to write a style for the width of a bar or the position of something.
In the islands I replaced those styles with classes, case by case:
-
The Auth Lab diagram places each actor with static Tailwind classes per column, instead of computing a position:
// Positions are static class names, never inline style attributes: the CSP (src/lib/csp.ts) blocks those. const COL_START = ['col-start-1', 'col-start-2', 'col-start-3', 'col-start-4'] -
The score bar in Live automation is drawn with SVG attributes: its width is a
<rect>’swidth, not a style. -
The three dots that pulse while the assistant types are staggered with classes like
[animation-delay:150ms].
Fonts as files
Another one I didn’t see coming: Vite turns files under 4 KB into data: URIs, and a few small font files (the extended Cyrillic ones) fell under that. The policy says font-src 'self', so those inlined fonts were blocked. The rule ended up like this in astro.config.ts:
build: { assetsInlineLimit: (file: string) => (/\.woff2?$/.test(file) ? false : undefined) },
Fonts are always files; everything else keeps Vite’s default limit.
Prism instead of Shiki
Astro highlights code with Shiki by default, and Shiki colors every token with a style attribute. Under this CSP, that’s one violation per highlighted word. Prism, on the other hand, only adds classes:
markdown: { syntaxHighlight: 'prism' },
The colors come from one of the site’s stylesheets, with a version for the light theme and one for the dark theme, each with good contrast. That’s why the code blocks in this post look the way they do.
The test that lets nothing through
A strict policy is only useful as long as nobody breaks it without noticing. That’s why e2e/csp.e2e.ts opens the pages of the build with Playwright, listens for the securitypolicyviolation event and the console messages, and fails on any violation:
for (const path of pages) {
test(`${path} has no CSP violations`, async ({ page }) => {
const violations = await watchCsp(page)
const response = await page.goto(path)
expect(response?.ok()).toBe(true)
await policy(page)
await wakeIslands(page)
if (TURNSTILE_PAGES.has(path)) await expect(page.getByText('Verificación simulada')).toBeVisible()
expect(violations).toEqual([])
})
}
The pages come from the build itself, in both languages, so a new page is covered without editing any list; of the pages that share a template, like the posts of this blog, it checks one. Before looking, it wakes up every island, because an island can load its code late. It also walks through the moments when a page loads more code: the quote calculator’s form with its Turnstile, the data migrator processing the sample file, every step of the Auth Lab diagram, and the theme switch, which redraws Turnstile. The Turnstile in the tests is a fake served at Cloudflare’s real address, so the CSP judges the same URL as in production. The 404 page goes through it too.
And performance?
You can’t tell. The policy is a <meta> tag with hashes, with no extra requests. The Lighthouse audit (local, median of 3 runs) gave 98 to 100 in all four categories, on mobile and desktop; the details are in docs/RENDIMIENTO.md (in Spanish).
Check it yourself
- The policy and its directives:
apps/web/src/lib/csp.ts, with its tests incsp.test.ts. - The boot script and its test:
boot.tsandboot.test.ts. - The headers that go with it:
public/_headers.
And if you want to see this site’s policy without opening the browser’s developer tools, Security in action reads it live, along with the other security headers.