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.
Related articles
- Laravel + Livewire Starter Kit: Docker-verified fork We took the official laravel/livewire-starter-kit (Livewire v4 + Flux + Alpine), ran it inside Docker, and published a production-hardened fork. Tests go from the upstream 33 passed to 37 passed / 92 assertions. This article covers the Liv…
- Implementing Content-Security-Policy in Laravel Content-Security-Policy (CSP) is the last defense layer that stops XSS damage in the browser. The server declares "these are the only scripts allowed to run and resources allowed to load on this page," and the browser rejects everything el…