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…
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:
- The Inertia starter kit article: "Inertia serializes initial props into an inline
<script>, so a nonce migration requires changes to Inertia's response rendering." - Livewire CSP nonce article: "Not drop-in portable to the React / Vue kits."
- Laravel CSP implementation guide: "Inertia's inline props are the obstacle."
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
- Implementing Content-Security-Policy in Laravel — nonce vs hash vs report-only
- Laravel + Livewire Starter Kit: nonce-based CSP — the Livewire side
- Hardening the Inertia Starter Kits — the base hardening
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
- 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…
- 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 …
- Hardening the Inertia Starter Kits We ran laravel/react-starter-kit and laravel/vue-starter-kit in Docker and published two forks rewritten to survive production. Tests went from 40 passed to 44 passed / 151 assertions in both.