{} 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-08-12

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)

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 → Hardening the Livewire Starter Kit

Why Livewire can go nonce-only

Correction (2026-07-23): this section originally argued that the React/Vue (Inertia) kits could not go nonce-only without patching Inertia, because Inertia serializes initial props into an executable inline <script>. That was wrong. Inertia emits a <script type="application/json"> data block, which the browser never executes and script-src therefore never gates. The Inertia kits can go nonce-based too, with no patching. Details and implementation: Inertia can run a nonce-based CSP. What follows stands as the Livewire-side explanation.

Livewire's architecture:

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

Re-checked 2026-07-23: this code lives in vendor, so an upstream change would quietly invalidate the approach described here. Checked against livewire/livewire on current main — the snippet above is still there verbatim.

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?

They port over fine. This section originally said they didn't — that patching Inertia was required. That was wrong.

Inertia's initial props ship as a <script type="application/json"> data block, which the browser never executes, so script-src doesn't gate it and there's no nonce to thread through Inertia. The only things needing a nonce in the React / Vue kits are the starter kit's own dark-mode <script> and background-colour <style> in app.blade.php — a nonce attribute on each.

The React variant with 'unsafe-inline' actually removed:

Inertia can run a nonce-based CSP (no patching required)

FAQ

Q. What does it take to wire a nonce through Livewire?

One call to Vite::useCspNonce($nonce) in middleware. Livewire's FrontendAssets::nonce() falls back to $options['nonce'] ?? Vite::cspNonce(), so Vite and Livewire both pick up the same value. That fallback is still present in current livewire/livewire — checked.

Q. Do the Flux directives accept a nonce?

Yes: @fluxAppearance(['nonce' => $cspNonce]) and @fluxScripts(['nonce' => $cspNonce]).

Q. Does this transfer to the React and Vue kits?

It does. This article originally said it could not, and that was wrong. Inertia's output is a non-executed data block, so nothing needs threading through Inertia. The React kit's removal is documented 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 origin/improvements origin/csp-nonce -- . ':!codelift'

License

  • Upstream: MIT (Laravel LLC and contributors)
  • Improvement: MIT (CodeLift / Libra)

Findings reflect the state on the verification date; upstream may change.

Related articles