CSP Hash on Hugo: migrating from nonce to hash to preserve CDN cache

The problem: CSP nonces and CDN caching don’t mix
When I migrated arleo.eu from Grav to Hugo, I tried to port the existing CSP nonce architecture. On Grav (PHP), it’s elegant: the backend generates a nonce per request, injects it into <script> tags via a plugin, and passes it to nginx in an X-CSP-Nonce header. nginx builds the CSP on the fly.
The problem: this approach is fundamentally incompatible with CDN caching. A different nonce per request means different HTML per request, which means Cloudflare can never serve from cache. HTML must be served with Cache-Control: no-store.
On a static Hugo site, this makes no sense. Content doesn’t change between requests — only the nonce does. The obvious solution is CSP hashing: compute the SHA-256 fingerprint of each inline script at build time, write it once into the nginx CSP, and leave it alone until the next build.
The architecture
The site runs on a NUC → Hugo VM reverse proxy. nginx on the NUC doesn’t serve static files directly: it proxies to the VM (proxy_pass http://192.168.122.69:80). This means the hash generation script must run on the VM, where public/ lives.
The nonce → hash migration involved three changes:
Remove the sub_filter directives that were injecting nonce="$csp_nonce" into every <script and <style tag. These didn’t touch content between tags (so hashes would have remained valid), but they were unnecessary and misleading.
Replace the nonce snippet (csp_nonce_report_only.conf) with a hash snippet generated automatically at each build.
Enable HTML caching on Cloudflare. With the nonce, HTML was sent with no-store. With hashes, it can be cached with s-maxage=86400 — Cloudflare serves HTML for 24 hours without touching the NUC.
The pipeline: csp-hash-gen.py
A pure stdlib Python script (zero external dependencies) runs on the VM after each hugo build:
- It walks all 393 HTML files in
public/ - It extracts every inline
<script>without asrcattribute (excludingapplication/ld+jsonblocks) - It computes the SHA-256 hash of each content block, base64-encoded
- It generates the nginx snippet with the complete CSP
The result is copied to the NUC at /etc/nginx/snippets/csp-hash-hugo.conf, then nginx is reloaded.
Classifying the 15 hashes
The audit produced 14 script hashes + 1 style hash, split into two categories:
Stable hashes (shared across many pages): the dark mode anti-flash script present on 198 pages, window.config variants by page type (with/without TypeIt, with/without Mermaid, FR or EN), and an easter egg on 2 pages.
Mermaid hashes (one per article with a diagram): each article embeds its full diagram in window.config.data, producing a unique hash per article. These hashes are stable at runtime — they only change when the diagram is edited. Across 6 affected articles, that means 6 fixed hashes until the next edit.
The practical consequence: every new article with a Mermaid diagram adds one hash to the CSP. The deploy-hugo.sh workflow regenerates the snippet automatically — no manual intervention needed.
Validation before enforcing
Before switching to enforce mode, the CSP ran as Content-Security-Policy-Report-Only to validate coverage. A cross-check script confirmed the 15 hashes cover 100% of the 393 HTML files without exception:
Known hashes in CSP: 15
✅ All inline blocks are covered by the current CSPZero violations. Enforce was activated immediately after.
Result
content-security-policy: default-src 'none';
script-src 'self' 'sha256-bAT2QS…' 'sha256-ENBKJb…' [13 more] ...;
style-src 'self' 'sha256-mbsscG…';
...
cf-cache-status: HITHTML is now cached by Cloudflare. CSP is enforced. Violation reports arrive at the /csp-report endpoint with the body correctly captured thanks to lua_need_request_body on.
The workflow for future articles: hugo build → deploy-hugo.sh → done.