{} CodeLift

Inertia Needs No Patch for a Nonce-Based CSP

Try to drop 'unsafe-inline' from the Content-Security-Policy of a Laravel + Inertia app and you'll usually land on some version of: "Inertia embeds the initial props in an inline script, so you can't — you'd have to patch Inertia or Handle…

Pub 2026-07-23 Verified 2026-07-23 Upd 2026-08-12

Verification environment

  • PHP 8.5.5
  • Laravel 13.x
  • Node 22.22.2
  • Frontend React 19 + Inertia
  • Base branch improvements
  • Tests 48 passed / 164 assertions
  • OS Docker Desktop (php:8.5-cli-bookworm)

Try to drop 'unsafe-inline' from the Content-Security-Policy of a Laravel + Inertia app and you'll usually land on some version of: "Inertia embeds the initial props in an inline script, so you can't — you'd have to patch Inertia or HandleInertiaRequests."

That's wrong. Inertia hands the page object over as a data block the browser never executes, so script-src never gates it. The thing actually forcing 'unsafe-inline' was not Inertia — it was two hand-written inline blocks in the starter kit itself.

This article records dropping 'unsafe-inline' on CodeLift's improved fork of the Laravel React Starter Kit. It is also a correction of claims CodeLift published earlier (see the end).

Deliverable: csp-nonce branch, 1 commit. Tests go from 44 passed on improvements to 48 passed / 164 assertions.

Where the misconception starts

Inertia connects server-side routing to React / Vue components. On navigation the server returns a JSON page object and the client renders it. On the first load, that page object has to reach the HTML somehow.

"Put JSON in HTML" → "must be an inline script" → "CSP blocks that" → "so you need 'unsafe-inline'" is a natural chain of reasoning. I wrote it too. It was reasoning done without reading the implementation.

What the implementation actually emits

Read compile() in vendor/inertiajs/inertia-laravel/src/Directive.php and render() in src/View/Components/App.php, and this is what @inertia / <x-inertia::app /> produces:

<script data-page="app" type="application/json">{...page object...}</script>
<div id="app"></div>

Note the type="application/json".

Per the HTML spec, a <script> whose type is neither a JavaScript MIME type nor module is a data block: its contents are never executed as script. Since it never executes, the inline restrictions in script-src don't apply to it. The client reads it with something like document.querySelector('[data-page]').textContent and JSON.parses it.

So there is nothing in Inertia to thread a nonce through. The thing people set out to patch doesn't exist.

What was actually requiring 'unsafe-inline'

The React Starter Kit's resources/views/app.blade.php contains two hand-written inline blocks:

{{-- dark mode detection --}}
<script>
    (function() {
        const appearance = '{{ $appearance ?? "system" }}';
        if (appearance === 'system') {
            const prefersDark = window.matchMedia('(prefers-color-scheme: dark)').matches;
            if (prefersDark) {
                document.documentElement.classList.add('dark');
            }
        }
    })();
</script>

{{-- background colour --}}
<style>
    html { background-color: oklch(1 0 0); }
    html.dark { background-color: oklch(0.145 0 0); }
</style>

These are genuinely executable inline code, and CSP does gate them. They have to run synchronously in <head> to avoid a flash of the wrong theme, so they can't move to an external file. This is what needed the nonce.

And adding a nonce attribute is all it takes — this is starter-kit code, unrelated to Inertia.

Implementation

1. Generate the nonce in middleware and publish it

$nonce = rtrim(strtr(base64_encode(random_bytes(16)), '+/', '-_'), '=');

app()->instance('csp-nonce', $nonce);
Vite::useCspNonce($nonce);          // auto-attaches to @vite script/link tags
View::share('cspNonce', $nonce);    // available in Blade as $cspNonce

Vite::useCspNonce() is built into Laravel and covers everything @vite(...) emits. View::share covers the hand-written blocks.

2. Put the nonce on the inline blocks

-<script>
+<script nonce="{{ $cspNonce ?? '' }}">
-<style>
+<style nonce="{{ $cspNonce ?? '' }}">

That's the whole change.

3. Drop 'unsafe-inline' from the CSP

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' lets the nonce-approved script (the Vite entry point) dynamically import further chunks without each chunk carrying a nonce — necessary because Vite splits its build output.

fonts.bunny.net is the font-CSS host the starter ships with, so it's allow-listed in style-src and font-src.

Pin it with tests

The danger with a nonce migration is that someone later adds an inline block without a nonce and it only breaks in production — CSP isn't enforced locally, so nobody notices.

So the fork includes a test that scans the rendered HTML and asserts every executable inline block carries the nonce.

public function test_every_inline_script_and_style_carries_the_nonce(): void
{
    $html = $this->get('/login')->getContent();
    $nonce = view()->shared('cspNonce');

    preg_match_all('/<script(?![^>]*\bsrc=)([^>]*)>/i', $html, $scripts);
    foreach ($scripts[1] as $attrs) {
        // type="application/json" is a data block — not subject to script-src
        if (preg_match('/type\s*=\s*"application\/json"/i', $attrs)) {
            continue;
        }
        $this->assertStringContainsString('nonce="'.$nonce.'"', $attrs);
    }

    preg_match_all('/<style([^>]*)>/i', $html, $styles);
    foreach ($styles[1] as $attrs) {
        $this->assertStringContainsString('nonce="'.$nonce.'"', $attrs);
    }
}

Add an inline block without a nonce and the suite fails before production does.

There's also a test pinning the premise of this whole article, so that a future Inertia release switching to an executable inline script doesn't slip by unnoticed:

public function test_inertia_page_payload_is_a_json_data_block(): void
{
    $html = $this->get('/login')->getContent();

    $this->assertMatchesRegularExpression(
        '/<script[^>]*data-page[^>]*type="application\/json"[^>]*>/i',
        $html
    );
}

Plus checks that the nonce changes per response (a fixed nonce defeats the point) and that the production CSP contains a nonce and no 'unsafe-inline'.

Results

Run inside Docker Desktop (PHP 8.5.5 / Laravel 13.x / Node 22.22.2).

improvements csp-nonce
php artisan test 44 passed / 151 assertions 48 passed / 164 assertions
script-src 'self' 'unsafe-inline' 'self' 'nonce-{req}' 'strict-dynamic'
style-src 'self' 'unsafe-inline' 'self' 'nonce-{req}' https://fonts.bunny.net
Patching Inertia not needed

No regressions in the existing tests.

The Vue kit is identical

The Vue Starter Kit uses the same Inertia integration: same data block from @inertia, same app.blade.php shape. The procedure carries over unchanged.

Compared with Livewire

CodeLift also migrated the Livewire Starter Kit to a nonce-based CSP. At the time the framing was "Livewire doesn't inline its initial state so it's easy; Inertia does, so it's hard."

The accurate framing:

Livewire Inertia (React / Vue)
Where initial state lives wire:* attributes on HTML elements <script type="application/json"> data block
Subject to script-src? No No
Framework patching needed No (nonce option exists) No (nothing to patch)
What actually needs a nonce Livewire / Flux bootstrap scripts The starter kit's own inline script / style

Both can go nonce-based. There was no meaningful difficulty gap.

Correction — errors in earlier CodeLift articles

This article corrects the following previously published claims:

All of these were written by reasoning about the behaviour instead of reading the implementation. In reality it's a type="application/json" data block, outside CSP's scope. Those articles have been corrected and now link here.

CodeLift's editorial rule is "don't assert what you haven't verified." This broke it. It's the same class of mistake as flagging the password rules as unaddressed when they were already handled — in both cases, a conclusion written before reading the code. Recorded rather than quietly deleted.

FAQ

Q. I read that Inertia makes it impossible to drop 'unsafe-inline'

It doesn't. Inertia mounts its initial props in <script data-page="app" type="application/json"> — a data block. A <script> whose type is not a JavaScript MIME type is never executed, so script-src does not cover it and no patch to Inertia is required.

The current release, inertiajs/inertia-laravel v3.1.1, still emits it that way.

Q. So what actually needs the nonce?

The inline code the starter kit itself writes into app.blade.php. On the React kit that was two places: the dark-mode <script> and a background-colour <style>. Adding a nonce attribute to each is the whole job.

Q. Do I need to modify HandleInertiaRequests?

No — there is nothing to modify. The claim that Inertia's response rendering has to be changed was an error we published ourselves, reasoned from the outside without reading the implementation. This article is the correction.

Q. Does the same approach work on the Vue kit?

Yes. Inertia emits identical HTML on both, so the csp-nonce changes transfer unmodified.

Reproduce and adopt

git clone https://github.com/codelift-dev/react-starter-kit.git
cd react-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:

git diff origin/improvements origin/csp-nonce -- . ':!codelift'

Related

Re-verified 2026-07-23

This article's conclusion rests on a single fact: what Inertia emits is a data block the browser never executes. So it was worth checking that this holds on the current release, not only the version used for the original run.

inertiajs/inertia-laravel at its latest release, v3.1.1:

File Output
src/Directive.php:24 <script data-page="..." type="application/json">
src/View/Components/App.php:29 <script data-page="..." type="application/json">

Both still carry type="application/json". The premise holds on the current release.

The fork also contains a test pinning that premise, so if a future Inertia release switches to an executable inline script, the test fails and we find out.

License

Upstream and improved fork both MIT. Findings reflect the verification date (2026-07-23).

Related articles