【実務完全ガイド】Laravelセキュリティ&信頼性強化――認証/認可・2FA/WebAuthn・CSP/ヘッダー・入力/ファイル・多要素復旧・監査ログ・マルチテナント・アクセシブルな安全設計
この記事で学べること(要点)
- Laravelにおける認証/認可(Fortify/Sanctum/Policies)の安全な組み立て
- 2FA・WebAuthn(パスワードレス)・復旧コード・デバイス承認と、だれにでも使いやすいUX
- 入力検証・ファイルアップロード・SSRF/コマンドインジェクションなど実務で起きる脆弱性対策
- HTTPS/HSTS/CSP/Permissions-Policy/Referrer-Policy などセキュアヘッダーの具体値と落とし穴
- 署名付きURL/署名Webhooks/Idempotency・キュー/ジョブの安全運用、秘密情報の保護と鍵ローテーション
- マルチテナント分離・監査ログ・PIIマスキング・バックアップ/リストア/レジリエンス
- チェックリスト・落とし穴・サンプルコード一式と、アクセシビリティを両立するエラーデザイン
想定読者(だれが得をする?)
- Laravel 初〜中級エンジニア:抜け漏れなく基本対策を固めたい方
- テックリード/PM:SaaS/社内基盤で標準の安全ガイドを策定したい方
- CS/QA/アクセシビリティ担当:多要素認証やエラー文を誰にでも理解できる形に整えたい方
アクセシビリティレベル:★★★★★
認証/2FA/ロックアウト/復旧の文言と導線、読み上げ(
role="status"/alert)、色非依存、キーボード操作、画像CAPTCHAの代替、prefers-reduced-motionに配慮した設計まで具体化しています。
1. 原則:安全と使いやすさは両立できます
セキュリティは、守る対象(資産)×攻撃面(攻撃対象領域)×運用のクセで決まります。Laravel は CSRF・XSS対策・暗号化・認可が標準装備ですが、運用で穴が空きやすいところ(ファイル・Webhooks・多要素認証の復旧・ログ/監査)に手当てが必要です。さらに、ログインや2FAの導線が難しければ人が回避してしまいます。この記事では、守りと使いやすさを同時に高める現場手法を示します。
2. 認証:Fortify/Sanctum とセッションの堅牢化
2.1 パスワードの方針
- 12文字以上、複雑さより長さを重視(辞書攻撃に強い)
- 貼り付け禁止にしない(パスワードマネージャ支援)
Hash::make()(bcrypt/argon2id)を使用。再ハッシュはneedsRehash()で
if (Hash::needsRehash($user->password)) {
$user->password = Hash::make($plain);
$user->save();
}
2.2 セッション固定化対策
- ログイン成功時にセッション再生成(Fortify/Laravelは既定で実施)
- セッションはサーバサイド(Redis等)で管理、
SameSite=Lax以上、Secure/HttpOnlyを有効
// config/session.php
'driver' => 'redis',
'secure' => true,
'http_only' => true,
'same_site' => 'lax',
2.3 Sanctum とスコープ(Abilities)
- SPA:Cookieベース(状態ful)
- API/モバイル:個別トークン+最小権限(abilities)
- 重要操作は追加検証(再認証/2FA)を挟む
// 発行例
$token = $user->createToken('cli', ['orders:read','orders:create'])->plainTextToken;
// チェック
abort_unless($request->user()->tokenCan('orders:create'), 403);
3. 多要素認証(2FA)とパスワードレス(WebAuthn)
3.1 TOTP(アプリ型2FA)
- Fortifyで TOTP/Recovery codes を有効化
- 2FA入力画面はアクセシブルに:
label・aria-describedby・inputmode="numeric"、エラーはrole="alert"
<label for="otp">6桁コード</label>
<input id="otp" name="code" inputmode="numeric" autocomplete="one-time-code"
aria-describedby="otp-help" class="w-40">
<p id="otp-help" class="text-sm text-gray-600">認証アプリに表示された6桁を入力</p>
- 連続失敗時は穏やかなメッセージと
Retry-Afterを提示(ロックアウト暴露を避ける)
3.2 復旧コードとバックアップ手段
- 有効化時に復旧コードをその場で保存させる
- 失くした場合のサポート導線を明示(本人確認の流れを簡潔に説明)
- コードはハッシュ化保存し、使ったら無効化
3.3 WebAuthn(生体/セキュリティキー)
- パスワードレス/多要素どちらでも使える
- 登録/認証UIはキーボード操作と読み上げを優先、キャンセル時の説明を短文で
// 簡易例(PublicKeyCredentialCreationOptions はサーバ生成)
const cred = await navigator.credentials.create({ publicKey: options });
4. 認可(Authorization):Gate/Policy と最小権限
- 行単位のルールは Policy に集約(
view/update/delete) - 管理UIでは権限一覧を可視化し、ロールを分解して付与
- 画面の非表示だけでなくサーバ側でも強制
public function update(User $user, Order $order): bool
{
return $order->user_id === $user->id || $user->can('orders:update:any');
}
5. 入力/出力の安全:XSS/CSRF/SQLi/テンプレート
5.1 XSS
- Blade の
{{ }}は自動エスケープ。HTML挿入が必要な場合は信頼済みの生成結果のみ{!! !!} - Markdown/リッチテキストはホワイトリストサニタイズ
5.2 CSRF
- フォームは
@csrfを必ず - APIはCookie認証なら
sanctum/csrf-cookie、トークン認証ならヘッダーでCSRF対象外に
5.3 SQLインジェクション
- プレースホルダとビルダを使用し、動的カラム/ORDER BYはホワイトリスト
$allowed = ['created_at','score'];
$col = in_array($req->get('sort'), $allowed, true) ? $req->get('sort') : 'created_at';
$query->orderBy($col, 'desc');
5.4 テンプレートインジェクション
- ユーザー入力を Blade ディレクティブや Alpine/JS の式に直埋めしない。属性値は
x-textを使用
6. ファイル/画像の安全:MIME検証・EXIF・スキャン・配信
6.1 バリデーション
$request->validate([
'file' => ['required','file','mimetypes:image/jpeg,image/png,application/pdf','max:8192'],
]);
- MIMEタイプをサーバで確認(拡張子信頼は不可)
6.2 EXIF/位置情報
- 画像はアップロード時にEXIFを除去、位置情報漏えいを防止
6.3 ウイルススキャン
- ClamAV などで非同期スキャン→陽性は隔離・通知・削除フロー
6.4 署名付きURL/権限
- 原本は非公開ストレージ、配布時のみ
temporaryUrl() Content-Disposition/Cache-Controlを明示し、意図しないダウンロードを防ぐ
7. 外部連携:HTTPクライアント・Webhooks・SSRF/再試行
7.1 送信側(Webhooks発火)
- 署名ヘッダーを付与(HMAC-SHA256など)、相手側は検証
- 冪等性キーを送ると重複配信に強い
$payload = json_encode($data);
$sig = hash_hmac('sha256', $payload, config('app.webhook_secret'));
Http::withHeaders(['X-Signature'=>$sig, 'Idempotency-Key'=>$uuid])->post($url, $data);
7.2 受信側(Webhooks受理)
- まず署名検証、リプレイ対策(時刻/nonce)
- 受信後はジョブに投げて非同期処理、同一イベントIDは無視(冪等)
7.3 SSRF/タイムアウト
Http::timeout(10)を必ず設定、接続/応答タイムアウトを分ける- 内部IP/メタデータIPへのアクセスを拒否(SSRF対策フィルタ)
- リダイレクト/Proxy の扱いを最小に
8. ヘッダー/HTTPS/CSP:ブラウザ側の防御を高める
8.1 TLS/HSTS
- 常時HTTPS、HSTS(
max-age=31536000; includeSubDomains; preload)を返す - クッキーは
Secure/HttpOnly/SameSite=Lax|Strict
8.2 セキュアヘッダー(例)
return response($html, 200, [
'Content-Security-Policy' => "default-src 'self'; img-src 'self' data:; script-src 'self' 'nonce-{$nonce}'; style-src 'self' 'unsafe-inline'; frame-ancestors 'none'",
'X-Frame-Options' => 'DENY',
'X-Content-Type-Options' => 'nosniff',
'Referrer-Policy' => 'strict-origin-when-cross-origin',
'Permissions-Policy' => 'geolocation=(), microphone=(), camera=()',
]);
- CSPは段階導入。まず
Content-Security-Policy-Report-Onlyで違反検知→修正→本番適用 - インラインスクリプトはnonceで限定
9. 署名URL/リンク・不正アクセス検知
- 重要な操作は署名付きルート(期限つきURL)を使う
$url = URL::temporarySignedRoute('reports.show', now()->addMinutes(30), ['id'=>$report->id]);
- 不正アクセス試行はRate Limiting+アカウントロック方針(過度な情報露出を避ける)
RateLimiter::for('login', fn($req)=>[Limit::perMinute(5)->by($req->ip())]);
10. ジョブ/キューとタスクの安全
- ジョブは冪等に:同じ入力→同じ出力
- ユニークロックで重複起動を防止、外部APIは
RateLimitedミドルウェア
public function middleware(): array {
return [ new \Illuminate\Queue\Middleware\RateLimited('external') ];
}
- 例外は分類(一時/恒久)、再試行回数と待ち時間(指数バックオフ)を明示
- 機微情報をジョブに直書きしない(IDのみ渡し、実体はサービス層で取得)
11. マルチテナント:境界を“構造で”守る
- 物理分離(DB/スキーマ)>論理分離(テーブルに
tenant_id) - クエリはグローバルスコープで自動制限、セッション/キャッシュもテナントごとに分離
- ストレージはパスにテナント識別子を付与し、署名URLでアクセスを限定
// グローバルスコープ例
protected static function booted() {
static::addGlobalScope('tenant', fn($q)=>$q->where('tenant_id', tenant()->id()));
}
12. ログ/監査とプライバシー
12.1 構造化ログ
request_id/user_id/ip/route/statusを必ず- PII(メール/氏名/住所)はマスクし、ログ出力前にフィルタを通す
Log::info('order.created', [
'request_id' => request()->header('X-Request-Id'),
'order_id' => $order->id,
'user_id' => auth()->id(),
'amount' => $order->amount,
]);
12.2 監査ログ(Audit)
- だれが/いつ/なにを/どの値に変えたか
- 認可/認証イベント・2FA設定/復旧コード表示など重要操作は必ず記録
13. 秘密情報/鍵管理・暗号化・バックアップ
13.1 .env とシークレット
.envは本番でのみ実値、KMS/シークレットマネージャ運用が理想- 共有は暗号化ファイル(age/sops 等)か専用金庫で
13.2 データ暗号化
Crypt::encryptString()/decryptString()で限定項目を暗号化- バックアップは保存先も暗号化し、鍵は別系統で保管
13.3 鍵ローテーション
- 新旧鍵の重ね使いで移行(旧鍵で復号→新鍵で再暗号)
14. エラーデザインとアクセシビリティ
- 認証/2FA/ロックアウトの文言は短く具体的。色だけに依存せず、
role="alert"で明示 - 迷子を防ぐ:次の手段(再送、復旧コード、サポート)を常に提示
- CAPTCHAは画像依存を避ける(人間らしさ検知/メールリンク/影時間/ハニーポット/RateLimit の組合せ)
- モーダル/トーストは
role="dialog"/status、フォーカス移動と戻り先を管理
<div role="alert" class="mb-2">コードが違います。もう一度お試しください。</div>
<p id="next" class="text-sm">復旧コードを使用する/サポートに連絡する</p>
15. 代表的なコード断片(抜粋)
15.1 ログイン試行のレート制限とロックアウト
RateLimiter::for('login', function ($request) {
$key = 'login:'.strtolower($request->input('email')).'|'.$request->ip();
return [Limit::perMinute(5)->by($key)];
});
// コントローラ
if (RateLimiter::tooManyAttempts($key, 5)) {
$seconds = RateLimiter::availableIn($key);
return back()->withErrors(['email'=>"試行回数が多すぎます。{$seconds}秒後に再試行してください。"]);
}
// 検証成功でクリア
RateLimiter::clear($key);
15.2 署名付きWebhook検証(受信側)
$payload = $request->getContent();
$signature = $request->header('X-Signature');
$calc = hash_hmac('sha256', $payload, config('services.partner.secret'));
abort_unless(hash_equals($calc, $signature), 401);
15.3 CSP用の nonce をテンプレートに渡す
// Middlewareで
$request->attributes->set('csp_nonce', bin2hex(random_bytes(16)));
// Blade
<script nonce="{{ request()->attributes->get('csp_nonce') }}">/* … */</script>
16. よくある落とし穴と回避策
- 2FA導線が難しい → 復旧コードと代替手段(メールリンク/セキュリティ質問は非推奨)を常に提示
- 画像CAPTCHAのみ → 代替手段を併用、ハニーポット+レート制限で負担軽減
- CSPを急に厳格化 → Report-Onlyで違反収集→修正→本番適用
- Webhooks検証なし → 署名検証/リプレイ対策/冪等化を最初に
- ファイルを public に直置き → 非公開保存+署名URL
- 任意ソート/検索の直渡し → ホワイトリスト
- ログにPIIだだ漏れ → マスキングとサニタイズ、アクセス権を最小に
- キューの再試行無制限 → 回数/待機上限とデッドレター方針
- テナントIDの付け忘れ → グローバルスコープ/ミドルウェアで強制
17. チェックリスト(配布用)
認証/認可
- [ ] パスワード方針(長さ重視、貼り付け許可、再ハッシュ)
- [ ] 2FA(TOTP/WebAuthn)と復旧コード、再認証フロー
- [ ] セッション
Secure/HttpOnly/SameSite、固定化対策 - [ ] Policy/Gate による最小権限
入力/出力
- [ ] XSS:自動エスケープ、サニタイズ、テンプレ直埋め禁止
- [ ] CSRF:
@csrf、APIは方針明確化 - [ ] SQLi:ビルダ使用、ソート/検索はホワイトリスト
ファイル/外部連携
- [ ] MIME検証/サイズ、EXIF除去、スキャン
- [ ] 署名付きURL、非公開保存
- [ ] Webhooks署名/リプレイ/冪等、HTTPタイムアウト/SSRF防止
ヘッダー/通信
- [ ] HTTPS/HSTS、クッキー属性
- [ ] CSP(Report-Only→本番)、XCTO/Frame-Options/Referrer/Permissions-Policy
キュー/運用
- [ ] 冪等化、ユニークロック、レート制限
- [ ] 監視とデッドレター、分類された再試行
マルチテナント/データ
- [ ] テナント境界(DB/スキーマ/スコープ)
- [ ] ストレージ分離、キャッシュ/セッション分離
ログ/秘密情報
- [ ] 構造化ログ(request_id 等)、PIIマスク
- [ ] 鍵管理/KMS、バックアップ暗号化、鍵ローテ
アクセシビリティ
- [ ] 認証/2FA画面の
label/aria-describedby/role="alert" - [ ] CAPTCHA代替、色非依存の状態表示
- [ ] モーダル/トーストのフォーカス移動/戻り先
18. まとめ
- Laravelの標準装備に加え、2FA/WebAuthn/署名URL/セキュアヘッダー/冪等ジョブを組み合わせ、攻撃面を狭めます。
- ファイル・Webhooks・テナント分離・秘密情報・ログの運用穴に手当てを。
- 認証や2FAの導線はアクセシブルで理解しやすく。次の手段を常に用意し、問い合わせコストを下げます。
- 「安全」は一度の設定では終わりません。監査/計測/改善のループで、静かに強いシステムへ育てましょう。
- 本記事のチェックリストと断片コードを土台に、チームの標準セキュリティガイドを整備してください。わたしも応援しています。
参考リンク
- Laravel 公式
- セキュアヘッダー/ブラウザ
- 標準/ガイド
- パスワードレス/2FA
