Contents

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

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-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

CriterionBeforeAfter
Script hashes460
Style hashes50
unsafe-hashespresentremoved
CSP size~4,000 chars~600 chars
Maintenancemanualautomatic
Reportingnon-functionalBetterStack structured
Cloudflare HTML cacheactivebypassed (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