Laravel + Livewire Starter Kit: nonce-based CSP
The SetSecurityHeaders middleware shipped in our Livewire Starter Kit Docker-verified fork kept 'unsafe-inline' in script-src and style-src. That was a deliberate placeholder to match the React/Vue forks; the Livewire architecture doesn't …
Verification environment
- PHP 8.5.5
- Laravel 13.x
- Composer 2.9.7
- Node 22.22.2
- npm 10.9.7
- Frontend Livewire v4 + Flux + Alpine
- Base branch improvements
- Database SQLite (tests)
- OS Docker Desktop (php:8.5-cli-bookworm)
Laravel + Livewire Starter Kit: nonce-based CSP
The SetSecurityHeaders middleware shipped in our Livewire Starter Kit Docker-verified fork kept 'unsafe-inline' in script-src and style-src. That was a deliberate placeholder to match the React/Vue forks; the Livewire architecture doesn't actually need it. This article is the follow-up that collects the deferred work: per-request CSP nonce, 'unsafe-inline' removed, CSP locked to 'nonce-{value}' 'strict-dynamic'.
Deliverable: csp-nonce branch, 1 commit (+67 lines). Test suite: 37 → 39 passed (92 → 102 assertions).
Sister article → Laravel + Livewire Starter Kit: Docker-verified fork
Why Livewire can go nonce-only while Inertia can't (easily)
The React/Vue variants use Inertia, which serializes initial component props into an inline <script> (or an attribute on a <div id="app"> consumed by an inline script). To remove 'unsafe-inline', you need to thread a nonce into Inertia's response rendering — a change to HandleInertiaRequests. That's real work and sits out of scope for the base hardening pass, so we kept 'unsafe-inline' there.
Livewire's architecture is different:
- Client JS (
livewire.js,flux.js) loads via external<script src="...">. - Component state lives on HTML elements as
wire:*attributes — not as an inline script blob. - Updates go through a fetch to
/livewire/update.
So the only inline things Livewire and Flux emit are small bootstrap <script> and <style> tags, and both packages already accept a nonce option. Digging into vendor/livewire/livewire/src/Mechanisms/FrontendAssets/FrontendAssets.php:
protected static function nonce($options = [])
{
$nonce = $options['nonce'] ?? Vite::cspNonce();
return $nonce ? "nonce=\"{$nonce}\"" : '';
}
It falls back to Vite::cspNonce() — meaning a single Vite::useCspNonce($nonce) call in our middleware makes both @vite(...) output and Livewire's auto-injected asset tags pick up the same nonce. Minimal wiring.
Implementation
One middleware rewrite, seven template edits, two test additions.
Promote SetSecurityHeaders to a nonce generator
Generate 16 random bytes as base64url at the start of handle(), then:
- Bind as
'csp-nonce'in the container (for anyone who wants it later). Vite::useCspNonce($nonce)— covers@viteand Livewire asset injection.View::share('cspNonce', $nonce)— exposes it to every Blade template.
$nonce = rtrim(strtr(base64_encode(random_bytes(16)), '+/', '-_'), '=');
app()->instance('csp-nonce', $nonce);
Vite::useCspNonce($nonce);
View::share('cspNonce', $nonce);
CSP policy: nonce + strict-dynamic, no 'unsafe-inline'
default-src 'self';
script-src 'self' 'nonce-XYZ' 'strict-dynamic';
style-src 'self' 'nonce-XYZ' https://fonts.bunny.net;
img-src 'self' data: blob:;
font-src 'self' data: https://fonts.bunny.net;
connect-src 'self';
frame-ancestors 'self';
base-uri 'self';
form-action 'self';
object-src 'none';
'strict-dynamic' means a nonce-approved script can dynamically import further scripts without those also carrying the nonce — needed for Vite bundle chunks.
fonts.bunny.net is this starter's default font host, so both style-src and font-src allowlist it. Swap for Google Fonts if you're switching.
Pass nonce into Flux directives
Every layout gets ['nonce' => $cspNonce ?? null] added:
@fluxAppearance(['nonce' => $cspNonce ?? null])
@fluxScripts(['nonce' => $cspNonce ?? null])
Files touched:
resources/views/partials/head.blade.phpresources/views/layouts/app/header.blade.phpresources/views/layouts/app/sidebar.blade.phpresources/views/layouts/auth/card.blade.phpresources/views/layouts/auth/simple.blade.phpresources/views/layouts/auth/split.blade.php
Annotate remaining inline styles
welcome.blade.php carries a large inline <style> block (the default Laravel welcome page). One attribute addition:
-<style>
+<style nonce="{{ $cspNonce ?? '' }}">
Tests
Two new assertions in tests/Feature/SecurityHeadersTest.php:
$cspNonceis shared to views and the value changes between responses (single-use property).- The production-mode CSP header excludes
'unsafe-inline', includes a nonce in bothscript-srcandstyle-src, and carries'strict-dynamic'.
public function test_production_csp_includes_nonce_and_forbids_unsafe_inline(): void
{
config()->set('app.env', 'production');
$this->app->detectEnvironment(fn () => 'production');
$response = $this->get('/login');
$csp = $response->headers->get('Content-Security-Policy');
$this->assertStringNotContainsString("'unsafe-inline'", $csp);
$this->assertMatchesRegularExpression("/script-src [^;]*'nonce-[A-Za-z0-9_-]+'/", $csp);
$this->assertMatchesRegularExpression("/style-src [^;]*'nonce-[A-Za-z0-9_-]+'/", $csp);
$this->assertStringContainsString("'strict-dynamic'", $csp);
}
Before / after
| Dimension | improvements branch |
csp-nonce branch |
|---|---|---|
script-src |
'self' 'unsafe-inline' |
'self' 'nonce-{req}' 'strict-dynamic' |
style-src |
'self' 'unsafe-inline' |
'self' 'nonce-{req}' https://fonts.bunny.net |
| Nonce plumbing | — | middleware → container + Vite + View share |
| Flux directives | @fluxAppearance / @fluxScripts |
both passed ['nonce' => $cspNonce] |
$cspNonce in Blade |
not defined | available site-wide |
| Tests | 2 | 4 (adds nonce uniqueness + nonce presence) |
What about the React / Vue forks?
Not drop-in portable. For the Inertia variants you'd need:
- The same middleware-level nonce generator (identical).
- Additionally, teach Inertia to emit the nonce on its inline
<script>. That means either patchingHandleInertiaRequests/ the Inertia response class, or moving to Inertia SSR so the initial props are server-rendered without an inline<script>in the first place.
Step 2 is its own article — I'll follow up separately.
Side effects and notes
'strict-dynamic'has fallback quirks in older browsers (Safari ≤ 15.3, legacy Chromium). Same-origin Vite bundles are unaffected in practice, but if you want to be conservative, drop'strict-dynamic'and live with each script carrying the nonce.- A CSP rollout normally goes through a
Content-Security-Policy-Report-Onlyphase withreport-uri/report-toendpoints to collect violations before enforcing. This commit ships the enforcing policy directly because the test + manual verification covered the known inline surfaces; add a reporter if your traffic mix differs (e.g. custom scripts added downstream). - Livewire's own asset route (
/livewire/livewire.jsetc.) is same-origin, soscript-src 'self'covers it. No extra host allow-listing needed.
Reproduce and adopt
git clone https://github.com/codelift-dev/livewire-starter-kit.git
cd livewire-starter-kit
git checkout csp-nonce
docker compose -f codelift/docker-compose.yml build
docker compose -f codelift/docker-compose.yml run --rm app
Diff against improvements only (excluding the CodeLift verification files):
git diff improvements csp-nonce -- . ':!codelift'
License
- Upstream: MIT (Laravel LLC and contributors)
- Improvement: MIT (CodeLift / JIT Inc.)
Findings reflect the state on the verification date; upstream may change.