akibeo / kirby-csp
Content-Security-Policy headers with per-request nonce for Kirby, following Google's strict CSP guidance.
Fund package maintenance!
Requires
- php: >=8.1
- getkirby/composer-installer: ^1.2
Requires (Dev)
- phpunit/phpunit: ^9.6 || ^10.5 || ^11.0
This package is auto-updated.
Last update: 2026-07-27 10:06:21 UTC
README
Sends a strict Content-Security-Policy header with a per-request nonce for Kirby, following Google's strict CSP guidance — 'strict-dynamic' + nonce, with https: / 'unsafe-inline' kept only as a legacy-browser fallback.
- Opt-in and per-host — disabled by default, enable per environment via
config.<host>.php. - Report-only rollout — test a policy against real traffic before enforcing.
- Per-request nonce —
cspNonce()helper for inline scripts, Vite tags, and third-party snippets. - Panel-safe — the header is only sent on frontend routes; Panel, API and media are left untouched.
Installation
Composer
composer require akibeo/kirby-csp
Download / Git submodule
Copy this repository into site/plugins/kirby-csp/:
git submodule add https://github.com/akibeo/kirby-csp.git site/plugins/kirby-csp
No build step is required — Kirby autoloads plugins from site/plugins/. The plugin registers itself as akibeo/csp and reads its options from the akibeo.csp namespace.
Porting to an existing site? Follow SETUP.md — a self-contained rollout guide (copy files, find scripts that need nonces, whitelist domains, test in report-only, enforce). It's written so you can also hand it to an AI agent as-is.
Configuration
The plugin is disabled by default. Enable it in site/config/config.php or a host config (config.<host>.php):
return [ 'akibeo.csp' => [ 'enabled' => true, // Optional: only send the header on these hosts (compared // lowercase, without port). Empty array = all hosts. 'hosts' => ['www.example.com'], // Optional: test the policy without enforcing it — sends // Content-Security-Policy-Report-Only instead. 'reportOnly' => true, // Required when Kirby's pages cache is enabled — see // "Pages cache" below. 'cacheSafe' => true, ], ];
Overriding directives
Directives are an associative array of directive => value. Config values merge over the defaults per directive, so you only specify what you change. {nonce} is replaced with the per-request nonce:
'akibeo.csp' => [ 'enabled' => true, 'directives' => [ 'frame-src' => "'self' https://www.youtube.com", ], ],
The defaults (see index.php) are a deliberately minimal, vendor-neutral strict baseline — everything is 'self' plus the 'strict-dynamic' + nonce script policy. Add the origins your project actually uses on top.
Example: Google Fonts + Analytics / Tag Manager + Maps
'akibeo.csp' => [ 'enabled' => true, 'directives' => [ 'style-src' => "'self' 'unsafe-inline' https://fonts.googleapis.com", 'font-src' => "'self' data: https://fonts.gstatic.com", 'img-src' => "'self' data: https:", 'connect-src' => "'self' https://www.google-analytics.com https://www.googletagmanager.com https://analytics.google.com https://region1.google-analytics.com", 'frame-src' => "'self' https://www.google.com", ], ],
See SETUP.md for a per-vendor directive table (Mapbox GL, Fontshare, GTM, reCAPTCHA, …).
Usage
Nonce for inline scripts
With 'strict-dynamic', inline scripts are blocked unless they carry the request nonce. Use the cspNonce() helper in templates and snippets:
<script nonce="{{ cspNonce() }}"> // inline script allowed by the CSP </script>
The nonce is generated once per request and memoized, so every call returns the same value that was sent in the header.
External scripts loaded by a nonced script are allowed automatically via 'strict-dynamic'; static <script src> tags need the nonce attribute too. Consent-gated <script type="text/plain"> tags and application/ld+json data blocks need no nonce — the former are re-injected by the (nonced) cookie-consent script, the latter are never executed.
Inline event handlers must be rewritten
A nonce lives on a <script> tag, so there is nothing to attach one to on onclick="…". The 'unsafe-inline' fallback in the default script-src does not cover them either — browsers ignore it once a nonce is present in the same directive. Every on*= attribute and href="javascript:…" therefore fails with "Executing inline event handler violates the following Content Security Policy directive". Move the behaviour into a bundled script:
<button onclick="toggleTheme()">…</button> {{-- blocked --}} <button type="button" data-theme-toggle>…</button> {{-- ok --}}
document.addEventListener('click', (event) => { if (event.target.closest('[data-theme-toggle]')) toggleTheme(); });
'unsafe-hashes' plus a per-handler hash would also work, but it weakens the policy and turns every handler edit into a config change. See SETUP.md for details.
Nonce for Vite tags
The tags printed by vite() are parser-inserted and need the nonce as well. The lukaskleinschmidt/kirby-laravel-vite plugin accepts a callable, resolved once per request:
'lukaskleinschmidt.laravel-vite' => [ 'nonce' => fn () => cspNonce(), ],
Script tags from Composer-managed plugins
Plugins whose folders are gitignored (e.g. a cookie-consent plugin) can't be patched in place. Override their snippet instead: a file with the registered snippet name in site/snippets/ (e.g. site/snippets/cookieconsentJs.php) takes precedence over the plugin's version — copy it and add 'nonce' => cspNonce() to the js() attribute arrays.
Finding scripts that need a nonce
Before enforcing, every inline <script> and static <script src> in the templates needs the nonce attribute. Quick greps:
# Inline and static script tags (excluding already-nonced ones) grep -rn "<script" site/templates site/snippets | grep -v "cspNonce()" # Inline event handlers — these can't be nonced, they must be rewritten grep -rnoEi '(^|[[:space:]])on[a-z]+="[^"]*"|href="javascript:[^"]*"' site/templates site/snippets # Third-party origins referenced anywhere in the frontend grep -rhoE 'https://[a-z0-9.-]+' site/templates site/snippets assets | sort -u
Pages cache
Kirby's pages cache (file, Redis, Memcached, … — the driver doesn't matter) stores the rendered HTML, including any nonces baked into it. The CSP header is regenerated with a fresh nonce on every request, so on a cache hit the header and HTML nonces no longer match — and since 'strict-dynamic' makes modern browsers ignore 'self', every script on a cached page would be blocked.
Enable cacheSafe to fix this:
'akibeo.csp' => [ 'enabled' => true, 'cacheSafe' => true, ],
With cacheSafe the HTML is cached with a stable placeholder instead of a real nonce (via page.render:after, whose output is what the pages cache stores), and the placeholder is swapped for the current request's nonce on every response — cache hits included — through an output buffer. Templates keep calling cspNonce() as usual; nothing else changes.
It's off by default because sites without a pages cache don't need the extra output buffering. Requires Kirby 4+ (the page.render:after hook).
Security note: the placeholder never appears in responses (only inside the cache), but the default value is public knowledge — it's a constant in this open-source repo. If untrusted, user-supplied HTML can end up in cached pages, an attacker could inject
<script nonce="…placeholder…">and receive a valid nonce after the swap, bypassing the nonce protection. On such sites, set a random per-site secret:'akibeo.csp' => [ 'cacheSafe' => true, // e.g. generate once with: php -r "echo bin2hex(random_bytes(16));" 'cacheSafePlaceholder' => 'nonce-ph-3f9c2a1d8e4b6f70a5c3d2e1b4a69808', ],The value must stay stable while cached pages exist — changing it requires flushing the pages cache. And as always, CSP is defense in depth: sanitize untrusted HTML regardless.
Testing a policy
- Set
'reportOnly' => trueand'enabled' => true. - Browse the site with DevTools open — violations show up in the console but nothing is blocked.
- Once the console stays clean, switch
reportOnlyoff to enforce.
See SETUP.md for the full rollout checklist and a vendor-specific directive table (Mapbox GL, Fontshare, GTM, reCAPTCHA, …).
Development
composer install composer test # vendor/bin/phpunit
The header-building logic lives in Akibeo\Csp\Csp (src/Csp.php) — pure, Kirby-free helpers covered by the test suite in tests/.
License
MIT — © E-xperience LAB
Credits
- Wannes Debusschere
- Yassine El Bouazzaoui