メインコンテンツまでスキップ

認証・認可設計 — JWT と層別の責務分担

この章で学ぶこと

この章では、DDDとクリーンアーキテクチャにおける認証(Authentication)と認可(Authorization) の設計について学びます。

  • 認証と認可の違い、各層での責務
  • JWT(JSON Web Token)の仕組みと構造
  • Laravel × JWT認証の実装
  • DDDにおける認可設計のパターン
関連する章

本章ではより詳細な認証設計と、SPA向けに広く使われるJWT認証について解説します。

認証と認可の違い

認証(Authentication)と認可(Authorization)は、しばしば混同されますが、明確に異なる概念です。

概念英語目的質問
認証Authenticationユーザーの身元確認「あなたは誰ですか?」
認可Authorization権限の確認「あなたはこれをする権限がありますか?」

クリーンアーキテクチャにおける認証・認可の位置づけ

認証と認可は、異なる層で扱うべき責務です。

認証と認可はプレゼンテーション層、ビジネスルールはドメイン層

認証は「誰がリクエストしているか」を特定する処理であり、HTTPリクエストの解析が必要なためプレゼンテーション層で行います。認可も「誰が何にアクセスできるか」の判定なので、Laravel では Policy がプレゼンテーション境界でこれを担います(第12章)。ドメイン層が持つのは「その操作が業務上許されるか」というビジネスルールです。

JWT(JSON Web Token)の基礎

JWTとは

JWTは、RFC 7519で定義された、JSON形式のクレーム(主張)を安全に転送するためのトークン形式です。署名により改ざんを検知でき、ステートレスな認証を実現できます。

JWTの構造

JWTは Header.Payload.Signature の3つの部分から成り、それぞれをBase64URLエンコードしてピリオド(.)で連結します。

eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9 . eyJzdWIiOiIxMjMiLCJpc3MiOiJteS1hcHAifQ . SflKxwRJSMeKKF2QT4f
─────────── Header ───────────────── . ────────────── Payload ─────────────── . ──── Signature ────

1. Header(ヘッダー)

{
"alg": "HS256", // 署名アルゴリズム(HMAC SHA-256)
"typ": "JWT" // トークンタイプ
}

2. Payload(ペイロード) — クレーム(主張)を含みます。

{
"sub": "123", // Subject: ユーザーID
"iss": "my-app", // Issuer: 発行者
"iat": 1699999999, // Issued At: 発行時刻
"exp": 1700000899, // Expiration: 有効期限
"role": "admin" // カスタムクレーム
}

3. Signature(署名)

HMACSHA256(
base64UrlEncode(header) + "." + base64UrlEncode(payload),
secret
)

ヘッダーとペイロードを秘密鍵で署名し、改ざんを検知可能にします。

主要なクレーム(Registered Claims)

クレーム名前説明
subSubjectトークンの対象(通常はユーザーID)
issIssuerトークンの発行者
iatIssued Atトークンの発行時刻(Unix時間)
expExpirationトークンの有効期限(Unix時間)
nbfNot Beforeこの時刻以前は無効
audAudienceトークンの対象者
jtiJWT IDトークンの一意識別子(無効化の管理に使う)

アクセストークンとリフレッシュトークン

JWTを使った認証では、2種類のトークンを使い分けるのが一般的です。

トークンのリフレッシュフロー:

なぜ2種類のトークンを使うのか

アクセストークンの有効期限を短くすることで、万が一トークンが漏洩しても被害を最小限に抑えられます。しかし、短い有効期限だけではユーザー体験が悪化するため、リフレッシュトークンを使って透過的にアクセストークンを更新します。

Sanctum vs JWT の使い分け

観点Laravel SanctumJWT(tymon/jwt-auth)
トークン形式ランダム文字列(DBに保存)自己完結型JWT
状態管理ステートフル(DB照会必要)ステートレス(署名検証のみ)
検証コストリクエストごとにトークンを1件DB照会署名検証のみでDB照会なし
トークン無効化即座に可能有効期限まで無効化困難
適したケースLaravelと同一ドメインのSPA・モバイル、ファーストパーティAPI複数サービスでトークンを共有する構成
セットアップLaravel標準追加パッケージ必要
どちらを選ぶべきか
  • Sanctum: Laravelと同一ドメインのSPAや、トークンの即時無効化が必要な場合
  • JWT: 複数サービス間での認証共有、高いスケーラビリティが必要な場合
  • Passport: OAuth2 の完全サポート(認可コードフロー、サードパーティクライアントへのトークン発行)が必要な場合。Laravel公式の選定基準は「OAuth2 が要るか」であり、規模ではありません

本書ではJWT認証を解説しますが、プロジェクトの要件に応じて適切な方式を選択してください。

Laravel × JWT認証の実装

パッケージのインストール

Laravel向けのJWT認証ライブラリとして広く使われているtymon/jwt-authを使用します。

対応バージョン

本章のコードは Laravel 11 以上、PHP 8.2 以上を前提としています。tymon/jwt-authv2.3.0(2026-03 リリース)以降が Laravel 9 〜 13 に対応しており、本書では ^2.3 の安定版を使用します。

# パッケージのインストール(安定版 ^2.3)
composer require tymon/jwt-auth:^2.3

# 設定ファイルの公開
php artisan vendor:publish --provider="Tymon\JWTAuth\Providers\LaravelServiceProvider"

# JWT署名用の秘密鍵を生成(.envに JWT_SECRET が追加される)
php artisan jwt:secret

認証ガードの設定

// config/auth.php

return [
'defaults' => [
'guard' => 'api', // デフォルトをAPIガードに
'passwords' => 'users',
],

'guards' => [
'web' => [
'driver' => 'session',
'provider' => 'users',
],

'api' => [
'driver' => 'jwt', // JWTドライバーを使用
'provider' => 'users',
],
],

'providers' => [
'users' => [
'driver' => 'eloquent',
'model' => App\Models\User::class,
],
],
];

Userモデルの実装

JWTSubjectインターフェースを実装する必要があります。

// app/Models/User.php

namespace App\Models;

use App\Enums\Role;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Foundation\Auth\User as Authenticatable;
use Tymon\JWTAuth\Contracts\JWTSubject;

class User extends Authenticatable implements JWTSubject
{
use HasFactory;

// roleは$fillableに入れない。認可の関心の列をリクエスト由来の値で埋めさせない
// という第13章と同じ扱いで、権限の自己申告を構造で塞ぐ
protected $fillable = [
'name',
'email',
'password',
];

protected $hidden = [
'password',
];

protected $casts = [
'role' => Role::class,
];

/**
* JWTの識別子(通常はプライマリキー)
*
* JWTSubject 側は戻り値型を宣言していないため、実装側で型を付けられる。
* プライマリキーの型に合わせて int|string に絞ることで型安全性を高める。
*/
public function getJWTIdentifier(): int|string
{
return $this->getKey();
}

/**
* JWTに含めるカスタムクレーム
*
* @return array<string, mixed>
*/
public function getJWTCustomClaims(): array
{
return [
'role' => $this->role->value,
];
}
}

認証コントローラの実装

FormRequestについて

以下のコードで使用するLoginRequestRegisterRequestは、第12章「プレゼンテーション層」で解説したFormRequestパターンに基づくバリデーションクラスです。emailpasswordのバリデーションを含むシンプルな実装です。

// app/Http/Controllers/AuthController.php

namespace App\Http\Controllers;

use App\Http\Requests\LoginRequest;
use App\Http\Requests\RegisterRequest;
use App\Models\User;
use Illuminate\Http\JsonResponse;
use Illuminate\Support\Facades\Hash;

final class AuthController extends Controller
{
/**
* ユーザー登録
*/
public function register(RegisterRequest $request): JsonResponse
{
$user = new User();
$user->fill([
'name' => $request->name,
'email' => $request->email,
'password' => Hash::make($request->password),
]);
// 初期ロールは固定値を直接代入する。省くと $this->role が null のまま
// JWTのクレーム生成に入る
$user->role = 'user';
$user->save();

$token = auth()->login($user);

return $this->respondWithToken($token, 201);
}

/**
* ログイン
*/
public function login(LoginRequest $request): JsonResponse
{
$credentials = $request->only(['email', 'password']);

if (!$token = auth()->attempt($credentials)) {
return response()->json([
'error' => 'メールアドレスまたはパスワードが正しくありません',
'code' => 'INVALID_CREDENTIALS',
], 401);
}

return $this->respondWithToken($token);
}

/**
* ログアウト(トークンを無効化)
*/
public function logout(): JsonResponse
{
auth()->logout();

return response()->json([
'message' => 'ログアウトしました',
]);
}

/**
* トークンのリフレッシュ
*/
public function refresh(): JsonResponse
{
return $this->respondWithToken(auth()->refresh());
}

/**
* 認証中のユーザー情報を取得
*/
public function me(): JsonResponse
{
return response()->json(auth()->user());
}

/**
* トークンを含むレスポンスを生成
*/
private function respondWithToken(string $token, int $status = 200): JsonResponse
{
return response()->json([
'access_token' => $token,
'token_type' => 'bearer',
'expires_in' => auth()->factory()->getTTL() * 60, // 秒単位
], $status);
}
}

ルーティングの設定

// routes/api.php

use App\Http\Controllers\AuthController;
use App\Http\Controllers\OrderController;

// 認証不要のエンドポイント
Route::prefix('auth')->group(function () {
Route::post('/register', [AuthController::class, 'register']);
Route::post('/login', [AuthController::class, 'login']);
});

// 認証が必要なエンドポイント
Route::middleware('auth:api')->group(function () {
// 認証関連
Route::prefix('auth')->group(function () {
Route::post('/logout', [AuthController::class, 'logout']);
Route::post('/refresh', [AuthController::class, 'refresh']);
Route::get('/me', [AuthController::class, 'me']);
});

// ビジネスロジック
Route::prefix('orders')->group(function () {
Route::get('/', [OrderController::class, 'index']);
Route::post('/', [OrderController::class, 'store']);
Route::get('/{id}', [OrderController::class, 'show']);
Route::post('/{id}/confirm', [OrderController::class, 'confirm']);
Route::post('/{id}/cancel', [OrderController::class, 'cancel']);
});
});

JWT設定のカスタマイズ

// config/jwt.php(抜粋)

return [
// トークンの有効期限(分)
// 本番環境では15〜60分程度を推奨
'ttl' => env('JWT_TTL', 60),

// リフレッシュ可能な期間(分)
// この期間内であれば期限切れトークンをリフレッシュ可能
'refresh_ttl' => env('JWT_REFRESH_TTL', 20160), // 2週間

// 署名アルゴリズム
// HS256: 対称鍵(同じ秘密鍵で署名・検証)
// RS256: 非対称鍵(秘密鍵で署名、公開鍵で検証)
'algo' => env('JWT_ALGO', 'HS256'),

// 非対称鍵(RS256/ES256 系)を使う場合のみ設定する
// HS256 では 'secret'(.env の JWT_SECRET)だけを使うため空でよい
'keys' => [
'public' => env('JWT_PUBLIC_KEY'),
'private' => env('JWT_PRIVATE_KEY'),
'passphrase' => env('JWT_PASSPHRASE'),
],

// 必須クレーム
'required_claims' => [
'iss', // 発行者
'iat', // 発行時刻
'exp', // 有効期限
'nbf', // 有効開始時刻
'sub', // 対象(ユーザーID)
'jti', // トークンID(一意識別子)
],
];

RS256 に切り替えるには、鍵ペアを用意して JWT_PUBLIC_KEY / JWT_PRIVATE_KEY にファイルパスを設定し、JWT_ALGO=RS256 にします。php artisan jwt:secret が生成するのは HS256 用の対称鍵なので、RS256 では使いません。

DDDにおける認可設計

認可の責務分担

認可は、プレゼンテーション境界の Policy で「誰が何をできるか」(WHO) を判定し、ドメイン層で「その操作が業務上許されるか」(WHAT) を判定します。

UseCaseでの認可実装

UseCaseに渡す CancelOrderCommand第11章「ユースケース層」で定義済みです(orderIdreason の 2 フィールド)。認可はこのCommandに含めません。「誰がキャンセルできるか」はControllerの Gate::authorize() が判定済みだからです。

// app/Application/UseCase/Order/CancelOrderUseCase.php

namespace App\Application\UseCase\Order;

use App\Domain\Order\Exception\OrderNotFoundException;
use App\Domain\Order\OrderId;
use App\Domain\Order\OrderRepositoryInterface;

final class CancelOrderUseCase
{
public function __construct(
private readonly OrderRepositoryInterface $orderRepository,
) {}

/**
* @throws OrderNotFoundException
*/
public function execute(CancelOrderCommand $command): void
{
$orderId = new OrderId($command->orderId);

$order = $this->orderRepository->findById($orderId)
?? throw new OrderNotFoundException($orderId);

// 状態のルール(発送済みはキャンセル不可)は cancel() の中で判定される
$order->cancel($command->reason);

$this->orderRepository->save($order);
}
}

ドメイン層が持つのは状態のルールだけ

第6章「エンティティ」Order は注文者を保持しません。誰の注文かは認可の関心で、orders.user_id カラムを OrderModel 経由で見る Policy が判定します(第12章)。ドメイン層が持つのは「この状態からキャンセルできるか」だけです。

その判定は OrderStatus::canBeCancelled()(第6章)が持ち、Order::cancel() がそれを呼んで違反時に例外を投げます。UseCaseは $order->cancel($command->reason) を呼ぶだけで、状態のルールを自分で書く必要はありません。

認可を拒否したときのレスポンス

Policyが false を返すと Gate::authorize() は例外を投げ、403 が返ります。第16章「エラーハンドリング」bootstrap/app.php で、この例外を他のエラーと同じ error / code 形式に揃えてください。

ロールによる分岐はPolicyに置く

管理者だけが全注文を操作できる、のようなロール分岐も「誰が何をできるか」なので Policy の担当です。User モデルの $casts が参照する Role は次のように定義します。

// app/Enums/Role.php

namespace App\Enums;

enum Role: string
{
case USER = 'user';
case ADMIN = 'admin';
}

users.role カラムは第14章「ドメインモデルとテーブル設計」のマイグレーションで追加します。管理者バイパスは OrderPolicybefore() に置き、各メソッドにロール判定を散らしません。実装は第12章の OrderPolicyにあります。

Controllerでのユーザー情報取得

認証済みユーザーは auth()->user() で取得できます。ただしキャンセルAPIのControllerは第12章「プレゼンテーション層」に一本化してあります。Controllerは Gate::authorize('cancel', $order) で認可を通したあと、CancelOrderRequest から作ったCommandをUseCaseに渡すだけです。上のルーティング設定で登録した /{id}/cancel の実装がそれです。

セキュリティに関する注意点

JWTのセキュリティベストプラクティス

項目推奨事項
有効期限アクセストークンは短く(15分〜1時間)
署名アルゴリズムRS256(非対称鍵)を推奨。HS256の場合は秘密鍵を厳重に管理し、人が覚えられるパスワードを鍵に使わない(php artisan jwt:secret が生成する乱数を使う)
ペイロード機密情報(パスワード、個人情報)を含めない
HTTPS必ずHTTPSで通信(トークンの盗聴防止)
リフレッシュトークンHttpOnly Cookieに保存、DB保存時はハッシュ化
保存先アクセストークンはメモリに置く。localStorage はJavaScriptから常に読めるため、XSS 1 件で全トークンが漏れる
アルゴリズムの固定検証時に受け入れるアルゴリズムをサーバ側で固定する。トークンの alg ヘッダの値をそのまま使わない
iss / aud の検証config/jwt.phprequired_claims はクレームの存在しか確認しない。発行者や対象者を絞るなら、値の照合を自分で実装する

クレーム検証の詳細は RFC 8725 - JSON Web Token Best Current Practices の 3 章にまとまっています。

トークン無効化(ブラックリスト)

JWTはステートレスなため、即座の無効化が困難です。必要な場合は以下の方法を検討してください。

ブラックリストは既定で有効です(config/jwt.php)。

// config/jwt.php(抜粋)
'blacklist_enabled' => env('JWT_BLACKLIST_ENABLED', true),

この状態でログアウトすると、そのトークンがブラックリストに入り以降は使えなくなります。

auth()->logout(); // 内部でブラックリストに追加される
ブラックリストの注意点

ブラックリストはキャッシュ(Redis等)に保存されます。スケーラビリティを重視する場合は、アクセストークンの有効期限を短くし、ブラックリストを使わない設計も検討してください。

まとめ

ポイント説明
認証 vs 認可認証は「誰か」、認可は「何ができるか」
認証の層プレゼンテーション層(ミドルウェア)
認可の層プレゼンテーション層(Policy)
ビジネスルールの層ドメイン層(Entity / Value Object)
JWT構造Header.Payload.Signature
アクセストークン短い有効期限(15分〜1時間)
リフレッシュトークン長い有効期限、HttpOnly Cookieに保存
認可の実装Policy で所有者・ロールを判定し、ドメイン層で状態を判定

参考リソース

次のチャプターでは、これまで学んだ知識を統合して、注文システムを実際に実装していきます。