Inertia の CSP は nonce 化できる(本体の改造は不要)
Laravel + Inertia のアプリで Content-Security-Policy から 'unsafe-inline' を外そうとすると、「Inertia が初期 props を inline script に埋めるので無理」「Inertia 本体か HandleInertiaRequests を改造するしかない」という話に行き着くことが多い。
検証環境
- 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)
Laravel + Inertia のアプリで Content-Security-Policy から 'unsafe-inline' を外そうとすると、「Inertia が初期 props を inline script に埋めるので無理」「Inertia 本体か HandleInertiaRequests を改造するしかない」という話に行き着くことが多い。
これは誤りだった。 Inertia のペイロードはブラウザが実行しないデータブロックとして出力されるので、script-src の対象外である。'unsafe-inline' を強制していたのは Inertia ではなく、スターターキット自身が書いた 2 行のインラインコードだった。
本記事は、CodeLift が公開している Laravel React Starter Kit の改良版フォークで実際に 'unsafe-inline' を撤廃した作業の記録である。この記事は同時に、CodeLift が過去に公開した記述の訂正でもある(詳細は末尾)。
deliverable: csp-nonce ブランチ に 1 commit。テストは improvements の 44 passed から 48 passed / 164 assertions に増加。
誤解の出発点
Inertia はサーバー側ルーティングと React / Vue コンポーネントを繋ぐ。ページ遷移時はサーバーが JSON の page object を返し、クライアントがそれを描画する。問題は初回ロードで、このとき page object を HTML に載せる必要がある。
「HTML に JSON を載せる」→「inline script だろう」→「CSP でブロックされる」→「'unsafe-inline' が必要」という推論は自然で、私もそう書いた。が、実装を読まずに書いた推論だった。
実装が実際に出力しているもの
vendor/inertiajs/inertia-laravel/src/Directive.php の compile() と、src/View/Components/App.php の render() を読むと、@inertia / <x-inertia::app /> が出力するのはこれである。
<script data-page="app" type="application/json">{...page object...}</script>
<div id="app"></div>
type="application/json" が付いている。
HTML 仕様上、<script> の type が JavaScript の MIME タイプでも module でもない場合、その要素は データブロックとして扱われ、中身はスクリプトとして実行されない。実行されない以上 script-src の inline 制限の対象にならない。クライアント側の JS が document.querySelector('[data-page]').textContent を読んで JSON.parse する、という使い方をするための構造である。
つまり Inertia 側に nonce を通す必要はそもそも無い。改造すべき対象が存在しない。
本当に 'unsafe-inline' を要求していたもの
React Starter Kit の resources/views/app.blade.php に、手書きのインラインコードが 2 つある。
{{-- ダークモード判定 --}}
<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>
{{-- 背景色 --}}
<style>
html { background-color: oklch(1 0 0); }
html.dark { background-color: oklch(0.145 0 0); }
</style>
こちらは正真正銘の実行可能インラインコードで、CSP の対象になる。ダークモードのちらつきを防ぐため <head> で同期実行する必要があり、外部ファイルには出せない。nonce を付けるのが正解の対象はここだった。
そして nonce 属性を足すだけで済む。Inertia とは無関係の、スターターキット固有のコードだからだ。
実装
1. ミドルウェアで nonce を生成して配る
$nonce = rtrim(strtr(base64_encode(random_bytes(16)), '+/', '-_'), '=');
app()->instance('csp-nonce', $nonce);
Vite::useCspNonce($nonce); // @vite が出力する script/link に自動付与
View::share('cspNonce', $nonce); // Blade から $cspNonce で参照
Vite::useCspNonce() は Laravel 標準の仕組みで、@vite(...) が生成するタグに nonce を付けてくれる。手書きの分だけ View::share で Blade に渡す。
2. インラインブロックに nonce を付ける
-<script>
+<script nonce="{{ $cspNonce ?? '' }}">
-<style>
+<style nonce="{{ $cspNonce ?? '' }}">
これで終わり。
3. CSP から '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' は、nonce を通ったスクリプト(Vite のエントリポイント)が動的 import で読み込む子チャンクを、子側に nonce が無くても許可する。Vite のビルド成果物はチャンク分割されるので必要になる。
fonts.bunny.net はスターターキットが既定で読むフォント CSS のホストなので style-src と font-src に入れる。
テストで固定する
nonce 化で怖いのは「後から誰かがインラインブロックを足して、本番でだけ壊れる」こと。CSP 違反はローカル(CSP 非適用)では起きないので気付けない。
そこでレンダリング結果を走査して、実行可能なインラインブロックに 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" はデータブロックなので対象外
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);
}
}
インラインブロックが nonce 無しで追加されたら、本番で壊れる前にテストが落ちる。
さらに、この記事の前提そのものを固定するテストも入れた。Inertia が将来 inline script 方式に変わったら気付けるようにするため。
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
);
}
他に、nonce がレスポンスごとに変わること(固定 nonce は nonce の意味を失う)、本番 CSP に nonce が入り 'unsafe-inline' が入らないことも検証している。
検証結果
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 |
| Inertia 本体への改造 | — | 不要 |
既存テストの退行なし。
Vue 版も同じ
Vue Starter Kit も Inertia を使うので状況は同一である。@inertia の出力は同じデータブロック、app.blade.php の構造も同じ。同じ手順がそのまま通る。
Livewire との比較
CodeLift は Livewire Starter Kit でも CSP を nonce 化している。当時は「Livewire は初期 state を inline script に埋めないから nonce 化しやすい / Inertia は埋めるから難しい」と整理していた。
正しい整理はこうなる。
| Livewire | Inertia (React / Vue) | |
|---|---|---|
| 初期 state の持ち方 | HTML 要素の wire:* 属性 |
<script type="application/json"> データブロック |
script-src の対象か |
対象外 | 対象外 |
| フレームワーク本体への改造 | 不要(nonce オプション有り) | 不要(そもそも対象外) |
| 実際に nonce が要る箇所 | Livewire / Flux が注入する起動 script | スターターキット自作のインライン script / style |
どちらも nonce 化できる。 難易度に有意な差は無かった。
訂正 — CodeLift の過去記事の誤り
本記事は、CodeLift が過去に公開した以下の記述を訂正する。
- Inertia 系スターターキットの記事: 「Inertia は初期 props を inline
<script>に埋めるため、nonce 化には Inertia のレスポンスレンダリングへの改造が必要」 - Livewire の CSP nonce 化記事: 「React / Vue 版にそのままは適用できない」
- Laravel の CSP 実装ガイド: 「Inertia は inline props がネック」
いずれも Inertia の実装を読まずに、挙動を推測して書いたものだった。実際には type="application/json" のデータブロックであり、CSP の対象外である。該当記事は訂正済みで、本記事へのリンクを追加した。
CodeLift は「検証していない内容を断定しない」を運営ルールに置いている。今回はそれを破っていた。以前 パスワード強度ルールを「未対応」と誤判定した件と同じ種類のミスで、どちらも「実装を読む前に結論を書いた」ことが原因である。記録として残す。
よくある質問
Q. Inertia を使っていると CSP から 'unsafe-inline' を外せないと聞きました
外せます。Inertia が初期 props を載せるのは <script data-page="app" type="application/json"> というデータブロックで、type が JavaScript の MIME タイプでない <script> はブラウザが実行しません。実行されない以上 script-src の対象外なので、Inertia 本体に nonce を通す改造は不要です。
inertiajs/inertia-laravel の最新リリース v3.1.1 でもこの出力は変わっていません。
Q. では実際には何に nonce が必要ですか
スターターキット自身が app.blade.php に書いたインラインコードです。React Starter Kit の場合、ダークモード判定の <script> と背景色の <style> の 2 か所だけでした。それぞれに nonce 属性を足せば済みます。
Q. HandleInertiaRequests を改造する必要はありますか
ありません。改造すべき対象がそもそも存在しません。「Inertia のレスポンスレンダリングに手を入れる必要がある」という記述は当サイトが過去に書いた誤りで、実装を読まずに推測したものでした。本記事はその訂正でもあります。
Q. Vue 版でも同じ手順が使えますか
使えます。Inertia が出力する HTML は React 版と同一なので、csp-nonce ブランチの変更はそのまま通ります。
再現と取り込み
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
improvements からの差分だけを見る場合:
git diff origin/improvements origin/csp-nonce -- . ':!codelift'
関連記事
- Laravel で Content-Security-Policy を実装する — nonce・hash・report-only の使い分け
- Laravel + Livewire Starter Kit の CSP を nonce 化 — Livewire 側の実装
- Inertia 系スターターキットを検証して改良した — ベースとなる hardening
再検証(2026-07-23)
本記事の結論は「Inertia が出力するのは実行されないデータブロックである」という 1 点に依存している。そのため、検証に使った版だけでなく現行リリースでも成立するかを確認した。
inertiajs/inertia-laravel の最新リリース v3.1.1 の実装:
| ファイル | 出力 |
|---|---|
src/Directive.php:24 |
<script data-page="..." type="application/json"> |
src/View/Components/App.php:29 |
<script data-page="..." type="application/json"> |
どちらも type="application/json" を保っている。 前提は現行版でも成立する。
なお改良版フォークには、この前提そのものを固定するテストを入れてある。将来 Inertia が実行可能な inline script 方式に変わった場合、テストが落ちて気付ける。
ライセンス
元サンプル / 改良版とも MIT。検証結果は検証日(2026-07-23)時点のもの。
関連記事
- Laravel で Content-Security-Policy を実装する Content-Security-Policy(CSP)は、XSS(クロスサイトスクリプティング)の被害をブラウザ側で食い止める最後の防御層。サーバーが「このページで実行してよいスクリプト・読み込んでよいリソースはこれだけ」と宣言し、ブラウザがそれ以外を拒否する。
- Laravel + Livewire Starter Kit の CSP を nonce 化 Livewire Starter Kit の Docker 検証・改良版フォークで組んだ SetSecurityHeaders middleware は、Content-Security-Policy の script-src / style-src に 'unsafe-inline' を残していた。これは React / Vue 変種と揃えるための暫定措置で、Livewire 変種では 実際には不要 と前回の記事でも予告した通り。本記事ではその予告を回収する作業ログと、最…
- Inertia 系スターターキット(React / Vue)を検証して改良した 公式の laravel/react-starter-kit と laravel/vue-starter-kit を Docker で動かし、本番運用に耐える形へ書き直したフォークを 2 本公開している。テストはどちらも 40 passed → 44 passed / 151 assertions。