{} CodeLift

Laravel スターターキット共通 backend を本番化する 6 つの改修

Laravel 公式のスターターキット(React / Vue / Livewire)は、フロントエンド層こそ違うものの backend は同一の Laravel + Fortify コード を共有している。そのため、本番運用に向けた hardening も 3 つで共通する部分が大半を占める。本記事はその共通部分 — CodeLift が 3 つの fork すべてに適用した backend 改修 — を 1 か所にまとめた pillar(土台)記事である。

公開 2026-04-18 検証 2026-04-19 更新 2026-05-18

検証環境

  • PHP 8.5.5
  • Laravel 13.x
  • Composer 2.9.7
  • Node 22.22.2
  • npm 10.9.7
  • Database SQLite (tests)
  • OS Docker Desktop (php:8.5-cli-bookworm)

Laravel スターターキット共通 backend を本番化する 6 つの改修

Laravel 公式のスターターキット(React / Vue / Livewire)は、フロントエンド層こそ違うものの backend は同一の Laravel + Fortify コード を共有している。そのため、本番運用に向けた hardening も 3 つで共通する部分が大半を占める。本記事はその共通部分 — CodeLift が 3 つの fork すべてに適用した backend 改修 — を 1 か所にまとめた pillar(土台)記事である。

各スターターキット固有の話(ビルド構成・フロントエンドの落とし穴・各 fork の commit 一覧)は、それぞれの cluster 記事を参照:

この記事の位置づけ

CodeLift は公式サンプルを Docker で検証し、本番運用に耐える改良版を fork 公開している。3 つの starter kit を検証した結果、backend 由来の改善点 6 件(下記 B〜I)がすべての kit で完全に同じコード・同じ修正になることが分かった。同じ説明を 3 記事に複製するのは読者にとっても検索エンジンにとっても無価値なので、共通部分は本記事に集約した。

検証環境は全 fork 共通:

項目
基盤イメージ php:8.5-cli-bookworm + Node 22
PHP 8.5.5
Laravel Framework 13.x
Composer 2.9.7
検証日 2026-04-19
検証環境 Docker Desktop(ホストに言語ランタイムを入れない)

以下、各改修について「症状 → なぜ危険か → 改修 → 検証」の順で記録する。

B. .env.example が開発用デフォルトのみで production 値のヒントがない

症状

公式の .env.example は次の値で配布されている。

APP_ENV=local
APP_DEBUG=true
LOG_LEVEL=debug
SESSION_ENCRYPT=false
# SESSION_SECURE_COOKIE は記載なし

なぜ危険か

.env.example をコピーして .env を作るのは Laravel の標準手順。開発者がこのファイルの各行を 1 つずつ精査せずに本番へ持っていくと:

  • APP_DEBUG=true のまま → 例外画面にスタックトレース・環境変数・DB 接続情報が露出する。攻撃者にとっては設計図を渡すに等しい
  • LOG_LEVEL=debug のまま → リクエストの詳細・場合によっては認証情報や個人情報がログファイルに書き込まれ続ける
  • SESSION_ENCRYPT=false + SESSION_SECURE_COOKIE 未設定 → セッション Cookie が平文かつ HTTP でも送出されうる

これは「設定ミス」ではなく「example ファイルが production を想定していない」という構造的な不足。

改修

.env.example の該当キーにインラインコメントで production 推奨値を併記する。ランタイム挙動は一切変わらない、純粋なドキュメント改善。

APP_ENV=local
APP_DEBUG=true
APP_URL=http://localhost
# Production overrides:
#   APP_ENV=production
#   APP_DEBUG=false
#   APP_URL must be the https:// URL that TLS terminates at.

LOG_LEVEL / SESSION_* についても同様にコメントを足す。

C. config/app.php のタイムゾーンが文字列リテラル

症状

config/app.php の 68 行目が次のようにハードコードされている。

'timezone' => 'UTC',

なぜ危険か

危険というよりハマりどころ.envAPP_TIMEZONE=Asia/Tokyo を書いても、config/app.phpenv() を経由していないので一切反映されない。Laravel 本体の最新スケルトンは env('APP_TIMEZONE', 'UTC') に移行済みで、starter kit 側がその追従を漏らしている。

タイムゾーンがズレると、記事の公開日時・ログのタイムスタンプ・スケジュール実行が全部ズレる。デプロイ後に「なぜか 9 時間ズレる」と悩むことになる。

改修

1 行修正。

-    'timezone' => 'UTC',
+    'timezone' => env('APP_TIMEZONE', 'UTC'),

.env.example にも APP_TIMEZONE を候補として記載する。

D. セキュリティレスポンスヘッダの middleware がない

症状

bootstrap/app.php の web ミドルウェアスタックに、セキュリティヘッダを出すものが 1 つも登録されていない(具体的な初期状態は各 cluster 記事を参照 — React/Vue は Inertia 系 3 つ、Livewire は空)。結果として次のヘッダがすべて欠落する。

  • Content-Security-Policy
  • Strict-Transport-Security(HSTS)
  • X-Content-Type-Options
  • X-Frame-Options
  • Referrer-Policy
  • Permissions-Policy

なぜ危険か

これらは「多層防御」の層。1 つ 1 つは「これがあれば絶対安全」ではないが、欠けていると以下が素通しになる。

  • CSP なし → XSS の踏み台になるインラインスクリプト・外部スクリプト挿入を、ブラウザ側で止める最後の砦が無い
  • HSTS なし → 初回アクセスや http:// リンク経由で、中間者が HTTP に降格させる攻撃(SSL stripping)が成立しうる
  • X-Content-Type-Options: nosniff なし → ブラウザが Content-Type を「推測」して、本来ただのテキストファイルをスクリプトとして実行してしまう余地が残る
  • X-Frame-Options なし → 任意のサイトが iframe で埋め込んでクリックジャッキングを仕掛けられる

「リバースプロキシ側で付けるから」という前提はよくあるが、Laravel アプリが nginx + php-fpm だけで直に公開されるケースでは誰もヘッダを付けない。アプリ自身が安全側のデフォルトを持つべき。

改修

app/Http/Middleware/SetSecurityHeaders.php を新規作成し、web スタックに登録する。

  • 全環境共通: X-Content-Type-Options: nosniff / X-Frame-Options: SAMEORIGIN / Referrer-Policy: strict-origin-when-cross-origin / Permissions-Policy: camera=(), microphone=(), geolocation=()
  • 本番のみ: Strict-Transport-Security: max-age=31536000; includeSubDomainsContent-Security-Policy

HSTS と CSP を本番限定にする理由は明確で、(1) HSTS が localhost に焼き付くとローカルで HTTP に戻せなくなる、(2) CSP は dev サーバ(Vite HMR 等)と衝突しやすい、の 2 点。

CSP の中身はフロントエンドアーキテクチャによって変わる。Inertia(React/Vue)は初期 props をインラインスクリプトに埋めるため 'unsafe-inline' の扱いに注意が要り、Livewire はそれを必要としない。詳細と nonce 化の手法は各 cluster 記事および Livewire の CSP nonce 化記事を参照。

E. HTTPS scheme を強制するヘルパーがない

症状

リポジトリ全域を forceScheme / forceHttps で検索しても 0 件。HTTP→HTTPS の恒久リダイレクトも、scheme を固定する仕組みもない。

なぜ危険か

本番では TLS をリバースプロキシ(nginx / ロードバランサ / CDN)で終端し、backend には http:// で転送する構成が一般的。このとき Laravel は自分が HTTP で動いていると認識し、route()asset() が生成する URL がすべて http:// になる。

結果:

  • HTML 内のリンク・アセット URL が http:// → ブラウザが mixed-content として警告・ブロック
  • リダイレクト先が http:// → プロキシがまた https:// に戻し、運が悪いとリダイレクトループ
  • 生成された絶対 URL(メール内リンク・OGP・sitemap)が http:// で外部に出る

改修

AppServiceProvider::configureDefaults に 3 行追加する。

if (app()->isProduction()) {
    URL::forceScheme('https');
}

なお、プロキシが正しい X-Forwarded-Proto を送っているなら trusted proxies 設定でも解決できる。ただし trusted proxies の CIDR はインフラ構成に依存するため、ここでは scheme を直接固定する最小の手当てに留めている。

F. パスワード強度ルール(既に対応済み)

検証当初は「Password::default() がフレームワークデフォルト(8 文字・複雑性なし)のままでは?」と疑ったが、再監査の結果これは誤りだった

AppServiceProvider::configureDefaults に既に次が入っている。

Password::defaults(fn (): ?Password => app()->isProduction()
    ? Password::min(12)->mixedCase()->letters()->numbers()->symbols()->uncompromised()
    : null,
);

Password::defaults()(複数形、setter)で closure を登録すると、Password::default()(単数形、getter)がそれを解決する。本番では 12 文字以上・大小英数記号必須・Have I Been Pwned 照会付き。改修不要

「公式サンプルにもう入っている対策を、確認せず "未対応" と決めつけない」という戒めとして記録しておく。

G. ログインのレート制限が email + IP の 1 段だけ

症状

FortifyServiceProvider::configureRateLimiting のログイン制限は次の 1 段のみ。

RateLimiter::for('login', function (Request $request) {
    $throttleKey = Str::transliterate(Str::lower($request->input(Fortify::username())).'|'.$request->ip());
    return Limit::perMinute(5)->by($throttleKey);
});

なぜ危険か

email + IP をキーにした「5 回 / 分」は、1 つの IP から総当たりする素朴な攻撃には有効。しかし キーに IP が含まれるため、攻撃者が IP をローテーション(プロキシプール・ボットネット)すると毎回キーが変わり、制限が実質的に無効化される。1 つのアカウントに対して 1 時間あたり数千回の試行が、制限に一度も触れずに通ってしまう。

つまりこの制限は「IP を変えられない攻撃者」しか止められない。現実の credential stuffing は IP を変えてくる。

改修

RateLimiter::for複数の Limit を配列で返せる(すべて個別に評価され、どれか 1 つでも超えれば 429)。これを使って 2 段構えにする。

return [
    // バースト防御: email + IP で 5 回 / 分(既存の挙動)
    Limit::perMinute(5)->by($email.'|'.$ip),
    // アカウント単位の累積防御: email 単体で 20 回 / 時、IP に依存しない
    Limit::perHour(20)->by('login-account|'.$email),
];

2 段目は IP を含まないので、IP をいくらローテーションしても 1 アカウントあたり 1 時間 20 回で頭打ちになる。正規ユーザーがパスワードを 1 時間に 20 回間違えることはまずないので、誤検知のリスクも低い。

H. 2FA は使えるがポリシー強制の入口がない(設計判断)

Fortify の 2 要素認証機能は有効化されていて、UI・設定画面・テストも揃っている。ユーザーは opt-in で設定できる。

一方で次が無い。

  • 管理者アカウントに 2FA を必須化するポリシーヘルパー
  • 未設定ユーザーへのリマインダー / nag バナー
  • ロール別に 2FA 強制を切り替える仕組み

これは欠陥(defect)ではなく運用設計の判断。スターターキットに含めるべきか、各プロダクト側で決めるべきかはケースバイケース。CodeLift の改良 commit には含めず、判断材料として記録するに留める。

I. ログチャンネルがサブシステム別に分かれていない

症状

config/logging.php は default の stacksingle 構成。アプリログ・DB クエリログ・認証イベントがすべて同じ laravel.log に時系列で混ざる。

なぜ危険か

直接の脆弱性ではないが、インシデント対応の速度に効く。「先週、特定アカウントへの不審なログイン試行があったか」を調べたいとき、認証イベントが他のあらゆるログに埋もれていると grep 前提の調査になる。監査ログとしての保持期間も、アプリログと一緒くたでは適切に設定できない。

改修

2 ファイルを変更する。

  1. config/logging.phpauth チャンネルを追加(daily driver、90 日保持、LOG_AUTH_LEVEL / LOG_AUTH_DAYS で env 可変)
  2. app/Listeners/AuthActivitySubscriber.php を新規作成し、AppServiceProvider::bootEvent::subscribe 登録

subscriber が購読するイベント:

  • Illuminate\Auth\Events\Registered / Login / Logout / Failed / PasswordReset
  • Laravel\Fortify\Events\TwoFactorAuthenticationEnabled / TwoFactorAuthenticationDisabled

各レコードに user_id / email / ip / user_agent を構造化コンテキストで出力する。出力先が storage/logs/auth-YYYY-MM-DD.log に分離されるので、監査時に tail / grep が単純に通り、保持ポリシーも独立して設定できる。

まとめ — 共通 backend 改修の効果

観点 公式版 改良版
.env.example の production ヒント なし インラインコメントで併記
APP_TIMEZONE を env から設定 不可(ハードコード)
基本セキュリティヘッダ 一切なし 常時 4 種 + 本番のみ HSTS / CSP
HTTPS scheme 強制 なし 本番のみ URL::forceScheme('https')
ログインのレート制限 email+IP の 1 段 email+IP + email 単体の 2 段
認証イベントのログ 他ログと混在 専用 auth daily channel(90 日保持)

これら 6 改修は React / Vue / Livewire の 3 fork すべてに同一コードで適用されている。各 fork の実際の commit・テスト結果・フレームワーク固有の改修は、冒頭にリンクした cluster 記事を参照してほしい。

3 つの starter kit を production 適性で横断比較した記事もある:

ライセンス

  • 元サンプル: MIT(Laravel LLC / 開発チーム)
  • 改良版 fork: MIT(CodeLift / JIT 株式会社)

検証結果は検証日時点のもの。以降の upstream 更新で状況は変わりうる。

関連記事