From 46 Hashes to Zero: Migrating CSP to Dynamic Nonces

โก 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
๐ง 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: withstrict-dynamic, domain allowlists inscript-srcare 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-Endpointswas 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 โ BetterStackGrav Plugin: csp-nonce
A minimal plugin subscribed to onOutputGenerated:
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
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
# Inside location ~ \.php$ block
add_header Cache-Control "no-store" always;Eliminating unsafe-hashes
footer.html.twig:style="display: flex; ..."โ CSS class.footer-contenthero.html.twig:style="background-image: url(...)"โdata-hero-bgattribute read by JScookie-banner.js: dynamic<style>โ CSS moved tocustom.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-Onlymode in parallel to test future CSP changes without impacting users