---
title: "From 46 Hashes to Zero: Migrating CSP to Dynamic Nonces"
description: "Migrating CSP from 46 static hashes to dynamic nonces on nginx and Cloudflare: zero maintenance, enhanced security for a self-hosted homelab."
url: "https://www.arleo.eu/en/posts/csp-nonce/"
language: "en"
datePublished: "2026-04-15T13:22:00+02:00"
dateModified: "2026-05-15T23:51:25+02:00"
tags: ["cloudflare","nginx","security","infrastructure"]
categories: ["infrastructure"]
contentSignal: "ai-train=no, search=yes, ai-input=yes"
---

# From 46 Hashes to Zero: Migrating CSP to Dynamic Nonces
Migrating CSP from 46 static hashes to dynamic nonces on nginx and Cloudflare: zero maintenance, enhanced security for a self-hosted homelab.


aliases:
- /en/posts/grav-csp-nonce/

## ⚡ In short

The original CSP listed 46 SHA-256 hashes to cover inline scripts and styles — unmanageable, fragile, and approaching Cloudflare's 4,096 character limit. This migration to **dynamic nonces** reduces the CSP to ~600 characters, eliminates all manual hash maintenance, and adds structured violation reporting in BetterStack.

The Grav plugin powering this is available on GitHub:
🔌 Plugin : [jmrGrav/grav-plugin-csp-nonce](https://github.com/jmrGrav/grav-plugin-csp-nonce)

## 🧠 Why

When implementing a strict Content Security Policy on a Grav CMS site served through Cloudflare, the naive approach is to list SHA-256 hashes for every inline script. It works, but quickly becomes unmanageable. Several compounding problems:

- **Cloudflare limit**: Transform Rules have a 4,096 character limit. With 46 hashes, we were approaching the ceiling.
- **Misunderstood `strict-dynamic`**: with `strict-dynamic`, domain allowlists in `script-src` are ignored by CSP Level 3 browsers. Hashes for `<script src="...">` tags must match the exact HTML tag, not the JS file content.
- **Fragile**: an extra space or attribute in a `<script>` tag invalidates the hash. Cloudflare can also modify HTML on the fly.
- **No working reporting**: `Reporting-Endpoints` was missing, violations were never sent.

## 🔧 What was done

### Final Architecture

```
Visitor
  → Cloudflare (caches CSS/JS/images, bypasses HTML — no-store)
  → nginx (reads X-CSP-Nonce from FastCGI, composes CSP header)
  → PHP-FPM / Grav (generates nonce, injects into <script> and <style>)
  → CSP violations → /csp-report → nginx JSON log → Vector → BetterStack
```

### Grav Plugin: csp-nonce

A minimal plugin subscribed to `onOutputGenerated`:

```php
public function onPluginsInitialized(): void
{
    if ($this->isAdmin()) return;
    $this->nonce = base64_encode(random_bytes(16));
    $this->enable([
        'onTwigVariables'    => ['onTwigVariables', 0],
        'onOutputGenerated'  => ['onOutputGenerated', 0],
    ]);
}

public function onOutputGenerated(): void
{
    $nonce = $this->nonce;
    $output = preg_replace_callback('/<script(\s[^>]*)?(>)/i', function($m) use ($nonce) {
        if (strpos($m[1] ?? '', 'nonce=') !== false) return $m[0];
        return '<script' . ($m[1] ?? '') . ' nonce="' . $nonce . '">';
    }, $this->grav->output);
    $this->grav->output = $output;
    if (!headers_sent()) header('X-CSP-Nonce: ' . $nonce);
}
```

The nonce is generated with `random_bytes(16)` — 128 bits of entropy, impossible to predict.

### nginx: CSP Header Composition

```nginx
set $csp_nonce $upstream_http_x_csp_nonce;
fastcgi_hide_header X-CSP-Nonce;

add_header Content-Security-Policy "
  default-src 'none';
  script-src 'self' 'nonce-$csp_nonce' 'report-sample';
  style-src 'self' 'nonce-$csp_nonce' 'report-sample';
  font-src 'self';
  img-src 'self' www.gravatar.com data:;
  object-src 'none';
  upgrade-insecure-requests;
  report-to csp-endpoint;
" always;
```

### Cloudflare: HTML Cache Bypass

```nginx
# Inside location ~ \.php$ block
add_header Cache-Control "no-store" always;
```

### Eliminating unsafe-hashes

- `footer.html.twig`: `style="display: flex; ..."` → CSS class `.footer-content`
- `hero.html.twig`: `style="background-image: url(...)"` → `data-hero-bg` attribute read by JS
- `cookie-banner.js`: dynamic `<style>` → CSS moved to `custom.css`

### Summary

| Criterion | Before | After |
|---|---|---|
| Script hashes | 46 | 0 |
| Style hashes | 5 | 0 |
| `unsafe-hashes` | present | removed |
| CSP size | ~4,000 chars | ~600 chars |
| Maintenance | manual | automatic |
| Reporting | non-functional | BetterStack structured |
| Cloudflare HTML cache | active | bypassed (no-store) |

## 🏁 Conclusion

The migration to dynamic nonces radically simplifies CSP maintenance while strengthening its security. Each visitor receives a unique nonce per request — impossible to predict or reuse.

**To go further:**

- 💡 Add a BetterStack alert on repeated CSP violations to detect a real XSS injection attempt
- 💡 Implement `Content-Security-Policy-Report-Only` mode in parallel to test future CSP changes without impacting users



## Tags

- cloudflare
- nginx
- security
- infrastructure

## Categories

- infrastructure
