---
title: "CSP Hash on Hugo: migrating from nonce to hash to preserve CDN cache"
description: "CSP Hash on Hugo — migrating from nonce to SHA-256 hash for CDN cache compatibility"
url: "https://www.arleo.eu/en/posts/csp-hash-hugo/"
language: "en"
datePublished: "2026-05-16T00:00:00Z"
dateModified: "2026-05-16T06:16:14+02:00"
tags: ["nginx","security","hugo","cloudflare","csp","infrastructure"]
contentSignal: "ai-train=no, search=yes, ai-input=yes"
---

# CSP Hash on Hugo: migrating from nonce to hash to preserve CDN cache
CSP Hash on Hugo — migrating from nonce to SHA-256 hash for CDN cache compatibility



## 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 build` → `deploy-hugo.sh` → done.


## Tags

- nginx
- security
- hugo
- cloudflare
- csp
- infrastructure
