{} CodeLift

Inertia の CSP は nonce 化できる(本体の改造は不要)

Laravel + Inertia のアプリで Content-Security-Policy から 'unsafe-inline' を外そうとすると、「Inertia が初期 props を inline script に埋めるので無理」「Inertia 本体か HandleInertiaRequests を改造するしかない」という話に行き着くことが多い。

公開 2026-07-23 検証 2026-07-23 更新 2026-08-12

検証環境

  • 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.phpcompile() と、src/View/Components/App.phprender() を読むと、@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-srcfont-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 の実装を読まずに、挙動を推測して書いたものだった。実際には 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'

関連記事

再検証(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)時点のもの。

関連記事