We wanted this site to have a genuinely strict Content Security Policy — not the kind that technically exists but permits everything. The first version looked reasonable:
Content-Security-Policy: default-src 'none'; script-src 'self'; style-src 'self' 'unsafe-inline'
Every header scanner was happy. The site looked fine at a glance. Two things were broken anyway.
Inline JSON-LD is still a script
The first problem was structured data. Search engines read an Organization
block embedded in the page:
<script type="application/ld+json">{ "@type": "Organization", ... }</script>
That is a <script> element. CSP does not care that the browser will never
execute it as JavaScript — script-src 'self' forbids inline script content, and
the block is inline. Under a strict policy the structured data is refused, which
means the SEO work quietly stops existing.
The fix is a hash. Astro’s built-in CSP support computes a SHA-256 for every inline block it renders and emits them into the policy, so the JSON-LD is allowed by its exact content rather than by a blanket exception.
Hashes do not cover style attributes
The second problem was subtler, and it is the one worth remembering.
Our logo component sized itself with a CSS custom property on the element:
<span style="--sw-mark-h:24px;--sw-mark-w:14px">
CSP hashes apply to inline <style> blocks. They do not apply to style=""
attributes. There is no hash you can add that permits one. The only options are
'unsafe-inline', 'unsafe-hashes', or not using the attribute.
So the declaration was blocked, the custom properties never landed, and the SVG fell back to its intrinsic size. The logo rendered at the wrong dimensions in the header of every page. Nothing errored. Nothing looked obviously broken. The scanners still gave the site full marks, because a scanner reads headers — it does not open the page and check whether the design survived.
The fix was to stop using the attribute at all. Size now goes onto the SVG’s own
width and height attributes, which are HTML, not CSS, and are unaffected by
style-src:
const sized = markSvg.replace(/<svg\b/, `<svg width="${width}" height="${size}"`)
The stats grid had the same bug, for the same reason, and was fixed by moving the value into a class.
What we changed about testing
The lesson is not “CSP is hard”. It is that a passing header scan proves the header is present, not that the site still works. Those are different claims, and only one of them is what you actually care about.
Now the check loads the real pages in a headless browser and fails on any console violation:
chrome --headless --enable-logging=stderr --virtual-time-budget=6000 \
--dump-dom "https://safeworldstudios.com/" 2>log
grep -iE "violates|refused to" log
That catches the class of bug where the policy is technically correct and the page is quietly wrong. It found both problems above in about thirty seconds, after the header scanners had already told us everything was fine.
You can see the resulting policy on the security page, and check it against what this site actually sends.