RBAC と JWT 検証で Symfony API を保護する
このガイドでは、Logto が発行する ロールベースのアクセス制御 (RBAC) と JSON Web Token (JWT) を使用して、Symfony API に認可 (Authorization) を実装し、セキュリティを強化する方法を説明します。
始める前に
クライアントアプリケーションは Logto から アクセス トークン (Access token) を取得する必要があります。まだクライアント統合を設定していない場合は、React、Vue、Angular などのクライアントフレームワーク向け クイックスタート や、サーバー間アクセス用の マシン間通信 (M2M) ガイド をご覧ください。
このガイドは、Symfony アプリケーションにおけるこれらのトークンの サーバーサイド検証 に焦点を当てています。
学べること
- JWT 検証:アクセス トークン (Access token) を検証し、認証 (Authentication) 情報を抽出する方法
 - ミドルウェア実装:API 保護のための再利用可能なミドルウェアの作成
 - 権限モデル:さまざまな認可 (Authorization) パターンの理解と実装
- アプリケーション全体のエンドポイント向けグローバル API リソース
 - テナント固有の機能制御のための組織 (Organization) 権限
 - マルチテナントデータアクセスのための組織レベル API リソース
 
 - RBAC 統合:API エンドポイントでロールベースの権限 (Permission) とスコープ (Scope) を強制する方法
 
前提条件
- PHP の最新安定版がインストールされていること
 - Symfony および Web API 開発の基礎知識
 - Logto アプリケーションが設定済み(必要に応じて クイックスタート を参照)
 
権限モデルの概要
保護を実装する前に、アプリケーションアーキテクチャに適した権限モデルを選択してください。これは Logto の 3 つの主要な 認可 (Authorization) シナリオ に対応しています:
- グローバル API リソース
 - 組織(非 API)権限
 - 組織レベル API リソース
 

- ユースケース: アプリケーション全体で共有される API リソースを保護する(組織固有ではない)
 - トークンタイプ: グローバルオーディエンスを持つアクセス トークン
 - 例: パブリック API、コアプロダクトサービス、管理エンドポイント
 - 最適: すべての顧客が利用する API を持つ SaaS プロダクト、テナント分離のないマイクロサービス
 - 詳細: グローバル API リソースの保護
 

- ユースケース: 組織固有のアクション、UI 機能、ビジネスロジックを制御する(API ではない)
 - トークンタイプ: 組織固有オーディエンスを持つ組織トークン
 - 例: 機能ゲーティング、ダッシュボード権限、メンバー招待コントロール
 - 最適: 組織固有の機能やワークフローを持つマルチテナント SaaS
 - 詳細: 組織(非 API)権限の保護
 

- ユースケース: 特定の組織コンテキスト内でアクセス可能な API リソースを保護する
 - トークンタイプ: API リソースオーディエンス + 組織コンテキストを持つ組織トークン
 - 例: マルチテナント API、組織スコープのデータエンドポイント、テナント固有のマイクロサービス
 - 最適: API データが組織スコープとなるマルチテナント SaaS
 - 詳細: 組織レベル API リソースの保護
 
💡 進める前にモデルを選択してください — このガイド全体で選択したアプローチを参照します。
クイック準備手順
Logto リソースと権限の設定
- グローバル API リソース
 - 組織(非 API)権限
 - 組織レベル API リソース
 
- API リソースの作成: コンソール → API リソース にアクセスし、API を登録します(例: 
https://api.yourapp.com) - 権限の定義: 
read:productsやwrite:ordersなどのスコープを追加します – 権限付き API リソースの定義 を参照 - グローバルロールの作成: コンソール → ロール にアクセスし、API 権限を含むロールを作成します – グローバルロールの設定 を参照
 - ロールの割り当て: API アクセスが必要なユーザーまたは M2M アプリケーションにロールを割り当てます
 
- 組織権限の定義: 組織テンプレートで 
invite:memberやmanage:billingなどの非 API 組織権限を作成します - 組織ロールの設定: 組織テンプレートで組織固有のロールを設定し、それらに権限を割り当てます
 - 組織ロールの割り当て: 各組織コンテキスト内でユーザーに組織ロールを割り当てます
 
- API リソースの作成: 上記と同様に API リソースを登録しますが、組織コンテキストで使用します
 - 権限の定義: 組織コンテキストにスコープされた 
read:dataやwrite:settingsなどのスコープを追加します - 組織テンプレートの設定: API リソース権限を含む組織ロールを設定します
 - 組織ロールの割り当て: API 権限を含む組織ロールにユーザーまたは M2M アプリケーションを割り当てます
 - マルチテナント設定: API が組織スコープのデータとバリデーションを処理できることを確認します
 
ロールベースのアクセス制御ガイド からステップバイステップのセットアップ手順を始めましょう。
クライアントアプリケーションの更新
クライアントで適切なスコープをリクエストする:
- ユーザー認証 (Authentication): アプリの更新 → で API スコープや組織コンテキストをリクエスト
 - マシン間通信: M2M スコープの設定 → でサーバー間アクセスを設定
 
通常、クライアント設定を次のいずれか、または複数を含めるように更新します:
- OAuth フローでの 
scopeパラメーター - API リソースアクセス用の 
resourceパラメーター - 組織コンテキスト用の 
organization_id 
テストするユーザーまたは M2M アプリが、API に必要な権限を含む適切なロールまたは組織ロールに割り当てられていることを確認してください。
API プロジェクトの初期化
API 開発用の新しい Symfony プロジェクトを初期化するには、Symfony CLI または Composer を使用します。
Symfony CLI を使用する場合(推奨):
symfony new your-api-name --webapp
cd your-api-name
または Composer を使用する場合:
composer create-project symfony/skeleton your-api-name
cd your-api-name
composer require webapp
API 開発用の追加パッケージをインストールします:
composer require symfony/security-bundle
composer require symfony/serializer
composer require doctrine/annotations
開発サーバーを起動します:
symfony serve
または PHP の組み込みサーバーを使用する場合:
php -S localhost:8000 -t public/
これで基本的な Symfony プロジェクトが作成されます。API 開発用にフレームワークを設定します:
framework:
  secret: '%env(APP_SECRET)%'
  serializer:
    enabled: true
  property_access:
    enabled: true
コントローラー、サービス、その他の機能のセットアップ方法については、Symfony のドキュメントを参照してください。
定数とユーティリティの初期化
トークンの抽出と検証を処理するために、コード内で必要な定数やユーティリティを定義してください。有効なリクエストには、Authorization ヘッダーが Bearer <アクセス トークン (Access token)> の形式で含まれている必要があります。
<?php
class AuthConstants
{
    public const JWKS_URI = 'https://your-tenant.logto.app/oidc/jwks';
    public const ISSUER = 'https://your-tenant.logto.app/oidc';
}
<?php
class AuthInfo
{
    public function __construct(
        public readonly string $sub,
        public readonly ?string $clientId = null,
        public readonly ?string $organizationId = null,
        public readonly array $scopes = [],
        public readonly array $audience = []
    ) {}
    public function toArray(): array
    {
        return [
            'sub' => $this->sub,
            'client_id' => $this->clientId,
            'organization_id' => $this->organizationId,
            'scopes' => $this->scopes,
            'audience' => $this->audience,
        ];
    }
}
<?php
class AuthorizationException extends Exception
{
    public function __construct(
        string $message,
        public readonly int $statusCode = 403
    ) {
        parent::__construct($message);
    }
}
<?php
trait AuthHelpers
{
    protected function extractBearerToken(array $headers): string
    {
        $authorization = $headers['authorization'][0] ?? $headers['Authorization'][0] ?? null;
        if (!$authorization) {
            throw new AuthorizationException('Authorization ヘッダーがありません (Authorization header is missing)', 401);
        }
        if (!str_starts_with($authorization, 'Bearer ')) {
            throw new AuthorizationException('Authorization ヘッダーは "Bearer " で始まる必要があります (Authorization header must start with "Bearer ")', 401);
        }
        return substr($authorization, 7); // 'Bearer ' プレフィックスを削除
    }
}
Logto テナント情報の取得
Logto が発行したトークンを検証するには、次の値が必要です:
- JSON Web Key Set (JWKS) URI:JWT 署名を検証するために使用される Logto の公開鍵の URL。
 - 発行者 (Issuer):期待される発行者値(Logto の OIDC URL)。
 
まず、Logto テナントのエンドポイントを見つけます。これはさまざまな場所で確認できます:
- Logto コンソールの 設定 → ドメイン で確認できます。
 - Logto で設定した任意のアプリケーション設定内の 設定 → エンドポイント & 資格情報 で確認できます。
 
OpenID Connect ディスカバリーエンドポイントから取得する
これらの値は、Logto の OpenID Connect ディスカバリーエンドポイントから取得できます:
https://<your-logto-endpoint>/oidc/.well-known/openid-configuration
以下はレスポンス例です(他のフィールドは省略しています):
{
  "jwks_uri": "https://your-tenant.logto.app/oidc/jwks",
  "issuer": "https://your-tenant.logto.app/oidc"
}
コード内にハードコーディングする(推奨されません)
Logto では JWKS URI や発行者 (Issuer) のカスタマイズができないため、これらの値をコード内にハードコーディングすることも可能です。ただし、将来的に設定が変更された場合のメンテナンス負荷が増加する可能性があるため、本番アプリケーションでは推奨されません。
- JWKS URI:
https://<your-logto-endpoint>/oidc/jwks - 発行者 (Issuer):
https://<your-logto-endpoint>/oidc 
トークンと権限の検証
トークンを抽出し OIDC 設定を取得した後、次の点を検証してください:
- 署名:JWT は有効であり、Logto(JWKS 経由)によって署名されている必要があります。
 - 発行者 (Issuer):Logto テナントの発行者 (Issuer) と一致している必要があります。
 - オーディエンス (Audience):Logto に登録された API のリソースインジケーター、または該当する場合は組織コンテキストと一致している必要があります。
 - 有効期限:トークンが有効期限切れでないこと。
 - 権限 (スコープ) (Permissions (scopes)):トークンに API / アクションに必要なスコープが含まれている必要があります。スコープは 
scopeクレーム内のスペース区切り文字列です。 - 組織コンテキスト:組織レベルの API リソースを保護する場合、
organization_idクレームを検証してください。 
JWT の構造やクレームについて詳しくは JSON Web Token を参照してください。
各権限モデルで確認すべきこと
クレームや検証ルールは権限モデルによって異なります:
- グローバル API リソース
 - 組織(非 API)権限
 - 組織レベル API リソース
 
- オーディエンスクレーム (
aud): API リソースインジケーター - 組織クレーム (
organization_id): なし - チェックするスコープ(権限) (
scope): API リソース権限 
- オーディエンスクレーム (
aud):urn:logto:organization:<id>(組織コンテキストがaudクレームに含まれる) - 組織クレーム (
organization_id): なし - チェックするスコープ(権限) (
scope): 組織権限 
- オーディエンスクレーム (
aud): API リソースインジケーター - 組織クレーム (
organization_id): 組織 ID(リクエストと一致する必要あり) - チェックするスコープ(権限) (
scope): API リソース権限 
非 API 組織権限の場合、組織コンテキストは aud
クレーム(例:urn:logto:organization:abc123)で表されます。organization_id
クレームは組織レベル API リソーストークンにのみ存在します。
セキュアなマルチテナント API のため、必ず権限(スコープ)とコンテキスト(オーディエンス、組織)の両方を検証してください。
検証ロジックの追加
firebase/php-jwt を使用して JWT の検証を行います。Composer を使ってインストールしてください:
composer require firebase/php-jwt
まず、JWT 検証を処理するための共通ユーティリティを追加します:
<?php
use Firebase\JWT\JWT;
use Firebase\JWT\JWK;
use Firebase\JWT\Key;
class JwtValidator
{
    use AuthHelpers;
    private static ?array $jwks = null;
    public static function fetchJwks(): array
    {
        if (self::$jwks === null) {
            $jwksData = file_get_contents(AuthConstants::JWKS_URI);
            if ($jwksData === false) {
                throw new AuthorizationException('Failed to fetch JWKS', 401);
            }
            self::$jwks = json_decode($jwksData, true);
        }
        return self::$jwks;
    }
    public static function validateJwt(string $token): array
    {
        try {
            $jwks = self::fetchJwks();
            $keys = JWK::parseKeySet($jwks);
            $decoded = JWT::decode($token, $keys);
            $payload = (array) $decoded;
            // 発行者 (Issuer) の検証
            if (($payload['iss'] ?? '') !== AuthConstants::ISSUER) {
                throw new AuthorizationException('Invalid issuer', 401);
            }
            self::verifyPayload($payload);
            return $payload;
        } catch (AuthorizationException $e) {
            throw $e;
        } catch (Exception $e) {
            throw new AuthorizationException('Invalid token: ' . $e->getMessage(), 401);
        }
    }
    public static function createAuthInfo(array $payload): AuthInfo
    {
        $scopes = !empty($payload['scope']) ? explode(' ', $payload['scope']) : [];
        $audience = $payload['aud'] ?? [];
        if (is_string($audience)) {
            $audience = [$audience];
        }
        return new AuthInfo(
            sub: $payload['sub'],
            clientId: $payload['client_id'] ?? null,
            organizationId: $payload['organization_id'] ?? null,
            scopes: $scopes,
            audience: $audience
        );
    }
    private static function verifyPayload(array $payload): void
    {
        // 権限モデルに基づく検証ロジックをここに実装してください
        // この内容は下記の権限モデルセクションで説明します
    }
}
次に、アクセス トークンを検証するミドルウェアを実装します:
<?php
namespace App\Security;
use Symfony\Component\HttpFoundation\JsonResponse;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Security\Core\Authentication\Token\TokenInterface;
use Symfony\Component\Security\Core\Exception\AuthenticationException;
use Symfony\Component\Security\Http\Authenticator\AbstractAuthenticator;
use Symfony\Component\Security\Http\Authenticator\Passport\Badge\UserBadge;
use Symfony\Component\Security\Http\Authenticator\Passport\Passport;
use Symfony\Component\Security\Http\Authenticator\Passport\SelfValidatingPassport;
class JwtAuthenticator extends AbstractAuthenticator
{
    use AuthHelpers;
    public function supports(Request $request): ?bool
    {
        return $request->headers->has('authorization');
    }
    public function authenticate(Request $request): Passport
    {
        try {
            $token = $this->extractBearerToken($request->headers->all());
            $payload = JwtValidator::validateJwt($token);
            $authInfo = JwtValidator::createAuthInfo($payload);
            // 認証情報をリクエスト属性に保存し、汎用的に利用できるようにします
            $request->attributes->set('auth', $authInfo);
            return new SelfValidatingPassport(new UserBadge($payload['sub']));
        } catch (AuthorizationException $e) {
            throw new AuthenticationException($e->getMessage());
        }
    }
    public function onAuthenticationSuccess(Request $request, TokenInterface $token, string $firewallName): ?Response
    {
        return null; // コントローラーへ処理を継続
    }
    public function onAuthenticationFailure(Request $request, AuthenticationException $exception): ?Response
    {
        return new JsonResponse(['error' => $exception->getMessage()], Response::HTTP_UNAUTHORIZED);
    }
}
config/packages/security.yaml でセキュリティを設定します:
security:
  firewalls:
    api:
      pattern: ^/api/protected
      stateless: true
      custom_authenticators:
        - App\Security\JwtAuthenticator
権限モデルに応じて、JwtValidator 内で適切な検証ロジックを実装してください:
- グローバル API リソース
 - 組織 (非 API) 権限
 - 組織レベル API リソース
 
private static function verifyPayload(array $payload): void
{
    // オーディエンス (Audience) クレームが API リソースインジケーターと一致するか確認
    $audiences = $payload['aud'] ?? [];
    if (is_string($audiences)) {
        $audiences = [$audiences];
    }
    if (!in_array('https://your-api-resource-indicator', $audiences)) {
        throw new AuthorizationException('Invalid audience');
    }
    // グローバル API リソースに必要なスコープ (Scope) を確認
    $requiredScopes = ['api:read', 'api:write']; // 実際に必要なスコープに置き換えてください
    $scopes = !empty($payload['scope']) ? explode(' ', $payload['scope']) : [];
    foreach ($requiredScopes as $scope) {
        if (!in_array($scope, $scopes)) {
            throw new AuthorizationException('Insufficient scope');
        }
    }
}
private static function verifyPayload(array $payload): void
{
    // オーディエンス (Audience) クレームが組織フォーマットと一致するか確認
    $audiences = $payload['aud'] ?? [];
    if (is_string($audiences)) {
        $audiences = [$audiences];
    }
    $hasOrgAudience = false;
    foreach ($audiences as $aud) {
        if (str_starts_with($aud, 'urn:logto:organization:')) {
            $hasOrgAudience = true;
            break;
        }
    }
    if (!$hasOrgAudience) {
        throw new AuthorizationException('Invalid audience for organization permissions');
    }
    // 組織 ID がコンテキストと一致するか確認(リクエストコンテキストから取得する必要があります)
    $expectedOrgId = 'your-organization-id'; // リクエストコンテキストから取得
    $expectedAud = "urn:logto:organization:{$expectedOrgId}";
    if (!in_array($expectedAud, $audiences)) {
        throw new AuthorizationException('Organization ID mismatch');
    }
    // 必要な組織スコープ (Scope) を確認
    $requiredScopes = ['invite:users', 'manage:settings']; // 実際に必要なスコープに置き換えてください
    $scopes = !empty($payload['scope']) ? explode(' ', $payload['scope']) : [];
    foreach ($requiredScopes as $scope) {
        if (!in_array($scope, $scopes)) {
            throw new AuthorizationException('Insufficient organization scope');
        }
    }
}
private static function verifyPayload(array $payload): void
{
    // オーディエンス (Audience) クレームが API リソースインジケーターと一致するか確認
    $audiences = $payload['aud'] ?? [];
    if (is_string($audiences)) {
        $audiences = [$audiences];
    }
    if (!in_array('https://your-api-resource-indicator', $audiences)) {
        throw new AuthorizationException('Invalid audience for organization-level API resources');
    }
    // 組織 ID がコンテキストと一致するか確認(リクエストコンテキストから取得する必要があります)
    $expectedOrgId = 'your-organization-id'; // リクエストコンテキストから取得
    $orgId = $payload['organization_id'] ?? null;
    if ($expectedOrgId !== $orgId) {
        throw new AuthorizationException('Organization ID mismatch');
    }
    // 組織レベル API リソースに必要なスコープ (Scope) を確認
    $requiredScopes = ['api:read', 'api:write']; // 実際に必要なスコープに置き換えてください
    $scopes = !empty($payload['scope']) ? explode(' ', $payload['scope']) : [];
    foreach ($requiredScopes as $scope) {
        if (!in_array($scope, $scopes)) {
            throw new AuthorizationException('Insufficient organization-level API scopes');
        }
    }
}
ミドルウェアを API に適用する
これで、保護された API ルートにミドルウェアを適用します。
<?php
namespace App\Controller\Api;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\JsonResponse;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\Routing\Annotation\Route;
use Symfony\Component\Security\Http\Attribute\IsGranted;
#[Route('/api/protected')]
#[IsGranted('IS_AUTHENTICATED_FULLY')]
class ProtectedController extends AbstractController
{
    #[Route('', methods: ['GET'])]
    public function index(Request $request): JsonResponse
    {
        // リクエスト属性から認証 (Authentication) 情報へアクセス
        $auth = $request->attributes->get('auth');
        return $this->json(['auth' => $auth->toArray()]);
    }
}
保護された API のテスト
アクセス トークン (Access tokens) の取得
クライアントアプリケーションから: クライアント統合を設定している場合、アプリは自動的にトークンを取得できます。アクセス トークン (Access token) を抽出し、API リクエストで使用してください。
curl / Postman でのテスト用:
- 
ユーザートークン: クライアントアプリの開発者ツールを使い、localStorage またはネットワークタブからアクセス トークン (Access token) をコピーします。
 - 
マシン間通信トークン: クライアントクレデンシャルフローを使用します。以下は curl を使った非公式な例です:
curl -X POST https://your-tenant.logto.app/oidc/token \
-H "Content-Type: application/x-www-form-urlencoded" \
-d "grant_type=client_credentials" \
-d "client_id=your-m2m-client-id" \
-d "client_secret=your-m2m-client-secret" \
-d "resource=https://your-api-resource-indicator" \
-d "scope=api:read api:write"resourceやscopeパラメーターは API リソースや権限に応じて調整が必要です。API が組織スコープの場合はorganization_idパラメーターも必要になる場合があります。 
トークンの内容を確認したい場合は、 JWT デコーダー を使って JWT をデコード・検証できます。
保護されたエンドポイントのテスト
有効なトークンリクエスト
curl -H "Authorization: Bearer eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9..." \
  http://localhost:3000/api/protected
期待されるレスポンス:
{
  "auth": {
    "sub": "user123",
    "clientId": "app456",
    "organizationId": "org789",
    "scopes": ["api:read", "api:write"],
    "audience": ["https://your-api-resource-indicator"]
  }
}
トークンなし
curl http://localhost:3000/api/protected
期待されるレスポンス (401):
{
  "error": "Authorization header is missing"
}
無効なトークン
curl -H "Authorization: Bearer invalid-token" \
  http://localhost:3000/api/protected
期待されるレスポンス (401):
{
  "error": "Invalid token"
}
権限モデルごとのテスト
- グローバル API リソース
 - 組織 (非 API) 権限
 - 組織レベル API リソース
 
グローバルスコープで保護された API のテストシナリオ:
- 有効なスコープ: 必要な API スコープ(例: 
api:read,api:write)を含むトークンでテスト - スコープ不足: 必要なスコープがない場合は 403 Forbidden を期待
 - 誤ったオーディエンス: オーディエンスが API リソースと一致しない場合は 403 Forbidden を期待
 
# 必要なスコープがないトークン - 403 を期待
curl -H "Authorization: Bearer token-without-required-scopes" \
  http://localhost:3000/api/protected
組織固有のアクセス制御のテストシナリオ:
- 有効な組織トークン: 正しい組織コンテキスト(組織 ID とスコープ)を含むトークンでテスト
 - スコープ不足: ユーザーが要求された操作の権限を持たない場合は 403 Forbidden を期待
 - 誤った組織: オーディエンスが組織コンテキスト(
urn:logto:organization:<organization_id>)と一致しない場合は 403 Forbidden を期待 
# 別の組織用トークン - 403 を期待
curl -H "Authorization: Bearer token-for-different-organization" \
  http://localhost:3000/api/protected
API リソース検証と組織コンテキストを組み合わせたテストシナリオ:
- 有効な組織 + API スコープ: 組織コンテキストと必要な API スコープの両方を持つトークンでテスト
 - API スコープ不足: 組織トークンに必要な API 権限がない場合は 403 Forbidden を期待
 - 誤った組織: 別の組織のトークンで API にアクセスした場合は 403 Forbidden を期待
 - 誤ったオーディエンス: オーディエンスが組織レベルの API リソースと一致しない場合は 403 Forbidden を期待
 
# API スコープがない組織トークン - 403 を期待
curl -H "Authorization: Bearer organization-token-without-api-scopes" \
  http://localhost:3000/api/protected
さらに詳しく
実践でのロールベースのアクセス制御 (RBAC):アプリケーションのための安全な認可 (Authorization) の実装
マルチテナント SaaS アプリケーションの構築:設計から実装までの完全ガイド