【初心者〜実務まで】Laravelの認証・認可を正しく作る――ログイン/登録、Sanctum、Policy/Gate、ロール設計、セキュリティ、アクセシブルなフォームとエラー表示
- この記事は「Laravelでログイン機能を作りたいけれど、何が正解か分からない」方向けに、認証(Auth)と認可(Authorization)を一続きの設計として整理します
- UI(フォーム)とバックエンド(セッション/トークン/権限)を同時に整えるため、実務の落とし穴を避けやすいです
- アクセシビリティ(読み上げ・キーボード操作・エラー表示)を標準にするサンプルも含めます
想定読者(だれが得をする?)
- Laravelを始めたばかりの方:Breeze/Jetstream/Sanctumの違いが分からず、まず何から作れば良いか迷っている方
- 小規模〜中規模のWebアプリ開発者:ログイン/権限周りが場当たり的になり、運用で困り始めている方
- チーム開発のリード/レビュー担当:認可ルールをコードで統一し、画面の事故(見えてはいけないデータが見える)を減らしたい方
- デザイナー/QA/アクセシビリティ担当:フォームのエラー表示やログイン導線を、だれでも迷わず使える形に揃えたい方
アクセシビリティレベル:★★★★★
- ラベルと入力の紐付け、エラーサマリ、
aria-invalid/aria-describedby、色に依存しない必須/エラー表現 - セッション期限(419)や権限不足(403)時の案内を「次の行動」まで含めて設計
- キーボード操作でログイン〜設定変更まで完遂できる前提で構成
1. はじめに:認証と認可は「別物」ですが、同時に設計すると強くなります
Laravelでアプリを作り始めると、まず欲しくなるのがログイン機能です。ただ、ログインができるようになった次に必ず出てくるのが「この人は、この操作をして良いの?」という問題です。ここで認可の設計が後回しになってしまうと、画面やAPIが増えるほど、確認漏れが増えます。結果として、権限事故(見えてはいけない情報が見える、編集できてしまう)が起きやすくなります。
そこで、最初から次の2つをセットで考えます。
- 認証:あなたは誰ですか(ログイン/トークンで本人確認)
- 認可:あなたは何をして良いですか(権限チェック、所有者チェック、ロール)
この記事は、初心者の方が「とりあえず動いた」から一歩進んで、実務で安全に運用できる認証・認可を組み立てられるようになることを目標にしています。
2. まず選ぶ:Breeze / Jetstream / Fortify / 自前の違い
Laravelには認証まわりの導入方法がいくつかあります。結論としては、最初の一歩はBreezeが扱いやすいことが多いです。
- Laravel Breeze
- 最小構成で、ログイン/登録/パスワードリセットなど一式が揃う
- Blade版が分かりやすく、初学者向き
- Laravel Jetstream
- チーム機能、2FAなどを含む高機能セット
- 最初から全部欲しい場合は便利ですが、理解コストは上がりやすいです
- Laravel Fortify
- UIを持たない認証バックエンド(SPAや独自UI向け)
- 自前実装
- 学習には良いのですが、実務では「守るべきポイント(CSRF、セッション固定、レート制限等)」が抜けやすいので注意が必要です
最短で安全に進めたい場合は、Breeze(Blade)で土台を作り、必要に応じてAPI認証はSanctumで拡張する流れが分かりやすいです。
3. 認証の基本:セッションログインの流れ(Webアプリ)
3.1 ログインが成立するまで
一般的なWebアプリ(ブラウザ)では、次の流れが基本です。
- ログインフォームでメールとパスワードを送信
- サーバが本人確認(パスワードハッシュ照合)
- 成功したらセッションを作り、以後はセッションIDでログイン状態を維持
- CSRF対策とセットで「フォーム送信の安全性」を守る
Laravelはこの仕組みを標準で備えているため、変に独自化しない方が安全です。
3.2 重要な設定(.env)
APP_KEYは必ず生成されていること- 本番は
APP_DEBUG=false - セッションストア(Redis等)を選ぶ場合は運用も含めて検討
4. パスワード設計:強さ、リセット、通知の標準化
パスワードはセキュリティの中心ですが、強すぎるとサポートが増え、弱すぎると危険です。現場では「最低限の強さ+リセット導線を丁寧に」がバランスが良いです。
4.1 バリデーション例(FormRequest)
// app/Http/Requests/RegisterRequest.php
class RegisterRequest extends FormRequest
{
public function rules(): array
{
return [
'name' => ['required','string','max:50'],
'email' => ['required','email','max:255','unique:users,email'],
'password' => [
'required','string','min:12','confirmed',
],
];
}
public function attributes(): array
{
return [
'name' => 'お名前',
'email' => 'メールアドレス',
'password' => 'パスワード',
];
}
}
4.2 パスワードリセットは「事故が起きにくい文言」が大切です
- メールアドレスが存在しない場合でも「送信しました」と表示する(存在確認を漏らさない)
- 有効期限を明示し、期限切れ時は再発行導線へ誘導する
- メール本文はHTMLだけでなくプレーンテキストも用意する(読み上げや環境差に強い)
5. 認可の基本:PolicyとGateで「できる/できない」を統一する
認可は「if文で画面に散らす」と破綻しやすいです。Laravelでは、次の2つを軸にすると整理しやすくなります。
- Policy:モデル(例:Post/Project/Order)に対する権限をまとめる
- Gate:モデルがない判断(例:管理画面に入れるか、機能フラグ)をまとめる
5.1 Policyを作る
例として、プロジェクトを編集できるかどうかをPolicyで定義します。
php artisan make:policy ProjectPolicy --model=Project
// app/Policies/ProjectPolicy.php
class ProjectPolicy
{
public function view(User $user, Project $project): bool
{
return $user->tenant_id === $project->tenant_id;
}
public function update(User $user, Project $project): bool
{
if ($user->tenant_id !== $project->tenant_id) return false;
return $user->role === 'admin' || $user->role === 'owner';
}
public function delete(User $user, Project $project): bool
{
return $user->tenant_id === $project->tenant_id
&& $user->role === 'owner';
}
}
5.2 コントローラで使う(統一された入口)
public function edit(Project $project)
{
$this->authorize('update', $project);
return view('projects.edit', compact('project'));
}
この形にしておくと、画面の見た目がどう変わっても、権限の判定が一箇所に集約されます。レビューも「Policyだけ見れば良い」状態になり、漏れが減ります。
6. ロール設計:最初は少なく、でも“意味”は明確に
ロールを増やしすぎると管理が難しくなります。最初は次のような4段階が分かりやすいです。
- owner:契約、課金、削除など最重要
- admin:設定やメンバー管理
- member:通常操作
- viewer:閲覧のみ
重要なのは「ロール名」と「できること」をドキュメント化し、コード(Policy)で守ることです。画面にボタンを表示しないだけでは安全ではありません。必ずサーバ側で authorize を通してください。
7. API認証:Sanctumで「SPA」「外部クライアント」を無理なく扱う
Web(セッション)とは別に、APIを公開したいことがあります。LaravelではSanctumが扱いやすいです。
7.1 2つの使い方
- SPA認証(同一ドメインのSPA)
- セッション+CSRFを使いつつ、APIを叩くスタイル
- パーソナルアクセストークン(外部クライアント)
- トークンを発行し、
Authorization: Bearerで認証
- トークンを発行し、
7.2 トークン発行例(外部クライアント)
$token = $user->createToken('cli', ['orders:read'])->plainTextToken;
return response()->json(['token' => $token]);
7.3 ルート保護
Route::middleware('auth:sanctum')->get('/api/v1/orders', function () {
return OrderResource::collection(Order::latest()->paginate());
});
スコープ(abilities)を付けておくと、トークン漏洩時の被害を最小化できます。
8. レート制限:ログイン・リセット・招待は必ず守る
ログインやパスワードリセットは攻撃対象になりやすいので、レート制限を必ず検討します。Laravelは throttle で対応できます。
Route::post('/login', [AuthController::class, 'store'])->middleware('throttle:10,1');
- 1分に10回まで、のように現実的な値を決めます
- 429のときは「しばらく待ってからお試しください」と案内します
- 失敗時の文言は、情報を与えすぎない(メールが存在するか等)ようにします
9. アクセシブルなログインフォーム:ラベル、エラー、フォーカスの“標準形”
認証画面は多くの人が通るので、アクセシビリティの効果が出やすい場所です。ここでは「最小で効く型」を示します。
9.1 エラーサマリ(サーババリデーション)
@if ($errors->any())
<div id="error-summary" role="alert" tabindex="-1" class="border p-3 mb-4">
<h2 class="font-semibold">入力内容を確認してください。</h2>
<ul class="list-disc pl-5">
@foreach ($errors->all() as $error)
<li>{{ $error }}</li>
@endforeach
</ul>
</div>
<script>
(function(){
const el = document.getElementById('error-summary');
if (el) el.focus();
})();
</script>
@endif
- 送信後にサマリへフォーカスが移ると、読み上げでも状況が把握しやすいです
- 見た目の赤色だけに頼らず、テキストで説明します
9.2 入力とエラーの紐付け
@php $emailError = $errors->first('email'); @endphp
<label for="email" class="block font-medium">
メールアドレス <span class="sr-only">必須</span><span aria-hidden="true">(必須)</span>
</label>
<input id="email" name="email" type="email" value="{{ old('email') }}"
autocomplete="email"
aria-invalid="{{ $emailError ? 'true' : 'false' }}"
aria-describedby="{{ $emailError ? 'email-error' : 'email-help' }}"
class="border rounded px-3 py-2 w-full">
<p id="email-help" class="text-sm text-gray-600">例:hanako@example.com</p>
@if($emailError)
<p id="email-error" class="text-sm text-red-700">{{ $emailError }}</p>
@endif
同様にパスワードも label と aria-describedby を揃えます。これを一度テンプレ化(Bladeコンポーネント化)すると、全フォームが安定します。
10. 403/419/503の“困る画面”を丁寧にする
認証・認可でユーザーが混乱しやすいのが、権限不足やセッション期限です。ここを丁寧にすると問い合わせが減り、信頼が上がります。
- 403(権限がない)
- 何ができないかを短く説明し、「戻る」「権限を依頼する」など次の手段を提示
- 419(セッション期限/CSRF)
- 「長時間操作がなかったため、もう一度お試しください」と案内し、再ログイン導線を出す
- 503(メンテ/一時停止)
- 復旧目安、影響範囲、代替手段(サポート)を明示
アクセシビリティ的には、見出しを明確にし、リンク文言を具体的にします。「こちら」ではなく「ログイン画面へ戻る」などが親切です。
11. 認可を“画面表示”にも反映する(ただし最終防衛はPolicy)
「編集ボタンを表示するか」は、ユーザー体験として大切です。ですが、表示制御だけでは安全になりません。
- 表示:
@canで体験を整える - 実行:
authorizeで必ず拒否する
この二段構えが安定します。
@can('update', $project)
<a href="{{ route('projects.edit', $project) }}">編集</a>
@endcan
12. テスト:権限事故をCIで止める
認可は事故の影響が大きいので、Featureテストで守る価値が高いです。
12.1 別ユーザーが編集できない
public function test_user_cannot_update_other_users_project()
{
$t1 = Tenant::factory()->create();
$t2 = Tenant::factory()->create();
$user = User::factory()->create(['tenant_id' => $t1->id, 'role' => 'admin']);
$otherProject = Project::factory()->create(['tenant_id' => $t2->id]);
$this->actingAs($user);
app()->instance('tenant', $t1);
$res = $this->patch("/projects/{$otherProject->id}", ['name' => 'x']);
$res->assertForbidden();
}
12.2 ownerだけ削除できる
public function test_only_owner_can_delete()
{
$t = Tenant::factory()->create();
$owner = User::factory()->create(['tenant_id'=>$t->id, 'role'=>'owner']);
$admin = User::factory()->create(['tenant_id'=>$t->id, 'role'=>'admin']);
$p = Project::factory()->create(['tenant_id'=>$t->id]);
$this->actingAs($admin);
app()->instance('tenant', $t);
$this->delete("/projects/{$p->id}")->assertForbidden();
$this->actingAs($owner);
$this->delete("/projects/{$p->id}")->assertRedirect();
}
この2本があるだけでも、権限の基本事故をかなり防げます。
13. よくある落とし穴と回避策
- 認可が画面ごとにバラバラ
- Policyに集約し、コントローラで
authorizeを標準にする
- Policyに集約し、コントローラで
- ボタン非表示で安心してしまう
- 最終防衛はサーバ側。必ず
authorize
- 最終防衛はサーバ側。必ず
- ロールを増やしすぎる
- 最初は4段階程度に絞り、必要になってから拡張する
- パスワードリセット文言でユーザー存在を漏らす
- 成否に関わらず同じ案内に寄せる
- 419/403の説明が不親切
- 次の行動(ログイン、戻る、権限依頼)を必ず用意する
- エラーが色だけ
- サマリとテキストのエラー文、
aria-*で紐付ける
- サマリとテキストのエラー文、
14. まとめ:認証・認可は“仕組み”にすると、アプリが育っても壊れにくいです
Laravelの認証は、BreezeやSanctumを使うことで、比較的短時間で安全な土台を作れます。そこにPolicy/Gateを重ねて「何ができるか」をコードで統一すると、画面が増えても権限事故が起きにくくなります。さらに、ログインフォームやエラー表示をアクセシブルに整えることで、困る人を減らし、問い合わせも減り、結果として開発と運用の両方が楽になります。
最初の段階で、
- 認証(ログイン/リセット)
- 認可(Policyに集約、authorize徹底)
- レート制限(ログイン導線の保護)
- アクセシブルなフォーム(ラベル/エラー/フォーカス)
この4点を標準にしておくと、チームが安心して機能追加できる土台になります。

