{} CodeLift

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 …

Pub 2026-04-19 Verified 2026-04-19 Upd 2026-04-19

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 @vite and 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.php
  • resources/views/layouts/app/header.blade.php
  • resources/views/layouts/app/sidebar.blade.php
  • resources/views/layouts/auth/card.blade.php
  • resources/views/layouts/auth/simple.blade.php
  • resources/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:

  1. $cspNonce is shared to views and the value changes between responses (single-use property).
  2. The production-mode CSP header excludes 'unsafe-inline', includes a nonce in both script-src and style-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:

  1. The same middleware-level nonce generator (identical).
  2. Additionally, teach Inertia to emit the nonce on its inline <script>. That means either patching HandleInertiaRequests / 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-Only phase with report-uri / report-to endpoints 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.js etc.) is same-origin, so script-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