Contents

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:

  1. It walks all 393 HTML files in public/
  2. It extracts every inline <script> without a src attribute (excluding application/ld+json blocks)
  3. It computes the SHA-256 hash of each content block, base64-encoded
  4. 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 CSP

Zero 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: HIT

HTML 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 builddeploy-hugo.sh → done.