How we got an A+ on security headers with a static site
Hash-based CSP, the headers your framework leaves out and what Cloudflare can break: how this site reaches A+ on securityheaders.com and MDN Observatory.
This site gets an A+ on securityheaders.com and on MDN Observatory. You don’t have to take our word for it: you can check right now with the links in the “Verifiable” strip on the home page. Here is how it’s done and, above all, what breaks it without warning.
The obstacle: CSP
Almost every security header is one line of configuration. The hard one is the Content Security Policy (CSP), which tells the browser where it may load scripts and styles from. A useful CSP does not allow 'unsafe-inline' in script-src, and MDN Observatory takes 20 points off if it finds it. Without those points there is no A+.
The problem is that nearly every framework inserts inline scripts. There are two ways to allow them without 'unsafe-inline':
- Nonce: a random value that changes on every response. It forces the page to be generated on every visit, so you lose static HTML and CDN caching.
- Hash: the SHA-256 fingerprint of each script’s exact content. It is computed once, at build time, and works with static pages.
We chose hashes. Astro computes them at build time for every script and style it emits, and its Node adapter sends them in each prerendered page’s Content-Security-Policy header.
What the framework doesn’t do
While testing we found three gaps that would have cost us the A+ or broken the page.
1. Only the CSP travels. The adapter’s staticHeaders option carries the CSP, but not the other headers you set from middleware: they never reach static pages. So we serve the site with a small server of our own (server.mjs) that adds to every response:
Strict-Transport-Security: max-age=63072000; includeSubDomains; preload
X-Content-Type-Options: nosniff
Referrer-Policy: strict-origin-when-cross-origin
Permissions-Policy: camera=(), microphone=(), geolocation=(), payment=(), usb=()
Cross-Origin-Opener-Policy: same-origin
Cross-Origin-Resource-Policy: same-origin
X-Frame-Options: DENY
It lives in the repository and is tested in CI, not in a settings panel, so every change goes through review.
2. is:inline scripts get no hash. The script that prevents the dark-theme flash has to run before the first paint, so it is inline, and Astro does not hash it. We keep it in a single file, compute its hash in the config from that same file and declare it in the CSP. The bytes are identical by construction.
// astro.config.mjs: the hash comes from the same file inserted into the page
const themeInit = readFileSync('src/scripts/theme-init.js', 'utf8').trim();
const themeInitHash =
'sha256-' + createHash('sha256').update(themeInit).digest('base64');
export default defineConfig({
security: { csp: { scriptDirective: { hashes: [themeInitHash] } } },
});
3. A slip doesn’t warn you. If someone adds an inline script without a hash tomorrow, the browser blocks it silently. To keep that out of production, a build step walks all the generated HTML, hashes every inline <script> and <style> and fails if any is missing from its page’s CSP.
The resulting policy
It starts by denying everything and opens only what is needed:
default-src 'none'; base-uri 'none'; form-action 'self';
frame-ancestors 'none'; object-src 'none';
img-src 'self' data:; font-src 'self';
script-src 'self' 'sha256-…'; style-src 'self' 'sha256-…';
connect-src 'self' https://stats.bmstudio.dev;
upgrade-insecure-requests
Each page carries only the hashes it uses. https://stats.bmstudio.dev is our visit counter, running on our own server.
What breaks it from the outside
A hash-based CSP requires that nobody rewrites the HTML between your server and the browser. CDNs offer “optimizations” that do exactly that. On Cloudflare we keep three of them off:
- Rocket Loader, which rewrites how scripts load;
- Email Obfuscation, which inserts a script to hide email addresses;
- Web Analytics (RUM), which injects its own measurement script.
The third one actually happened to us. A weekly PageSpeed measurement scored 92 on Best Practices while CI measured 100. Cloudflare was inserting its analytics script, the CSP blocked it (as it should) and the console error cost points. CI couldn’t see it because it tests the build locally, without going through Cloudflare. Since then we measure production every week: it’s the alarm for whatever changes outside the repository.
The result, verifiable
On October 8, 2026, securityheaders.com gave an A+ with all six headers green, and MDN Observatory gave an A+ with 145/100, passing all 12 tests. You don’t need to remember these numbers: the site’s links rerun the scans whenever you like.
Takeaways
- If your site is static, use hashes, not nonces: you keep caching and speed.
- Put headers in code, next to what needs them, and test them in CI.
- Make the build fail if anything inline falls outside the CSP.
- Check what your CDN does to your HTML, and measure production, not just your build.
If you want this level of rigor in your application, tell us about your project.