🍢

CDK Mixinsで作る!ALB + JWT 検証

に公開

本記事は、Japan AWS Ambassadors Advent Calendar 2025 の6日目です。

導入

背景

  • 2025 年 11 月 12 日のアップデートにより、ALB でリクエストヘッダに含まれる JWT の署名・有効期限・クレームを検証できるようになりました。
  • この機能により、アプリコードの変更なしで ALB 側で JWT 検証を行うことができ、安全な M2M (Machine to Machine)通信を実現することが可能となります。
  • 本ブログでは、M2M 通信で利用される標準的な認可フローである OAuth 2.0 Client Credentials Grant の概要を解説したうえで、Auth0 が発行した JWT(アクセストークン) を ALB で検証するための AWS 資源を CDK を用いて構築してみます。

本記事の目的

  • Auth0 が発行した JWT を ALB で検証することで、アプリコードに手を入れることなく安全な M2M 通信を実現可能なことを解説します。
  • サブ目的として、CDK の Mixins を利用することで、L2 コンストラクトの抽象化による恩恵を受けつつ、L2 コンストラクトが未対応の新機能も型付きで簡単に設定可能なことを解説します。

対象読者

  • AWS Certified Solutions Architect - Professional レベルの知識を想定し、細かな AWS サービスに対する解説は割愛します。

OAuth 2.0 Client Credentials Grant の概要

OAuth 2.0 Client Credentials Grant とは、クライアント認証(もしくはその他サポートされている方式)のみを行い、アクセストークンを取得する方式です。
ユーザによる介入を必要としないことから、M2M 通信で利用されます。

なお、RFC 6749にも記載されているように、Client Credentials Grant はコンフィデンシャルクライアント(クライアントシークレット等のクレデンシャルを安全に保持できるクライアント)のみに許可されています。

The client credentials grant type MUST only be used by confidential clients.

JWT 検証用 ALB 資源の構築

環境構成

  • AWS 資源は CDK を用いて構築します。
  • Auth0 が発行した JWT(アクセストークン)を検証することで、M2M の認可処理を ALB に オフロードします。
  • 今回はローカルで実行する Curl コマンドがコンフィデンシャルクライアントだと見なして動作確認を行います。

Auth0 の事前設定

Auth0 アカウントにログイン後、「API」設定画面から新規 API を作成します。

識別子には今後作成する Docker アプリケーションの URL を指定します。また、RFC 9068 標準のクレームを使用するよう、JWT プロファイルには RFC 9068 を選択しています。

作成した API を選択して、パーミッションを追加しておきます。

「アプリケーション」設定画面にて「アプリケーションを作成」を選択します。「マシンツーマシンアプリケーション」を選択してアプリケーションを作成します。

先程作成した API 及びパーミッションを選択します。

設定完了後、上記アプリケーションの資格情報画面からクライアント ID とクライアントシークレットを控えておきます。

また、異常系の動作確認用に、同じ要領で異なる識別子を設定した API を作成したうえで、上記アプリケーションの認可済み API として追加しておきます。

Docker アプリケーション資源の構築

Flask を用いて、検証用 Web アプリ を構築します。

from flask import Flask, jsonify
from datetime import datetime, timezone, timedelta

app = Flask(__name__)

JST = timezone(timedelta(hours=9))

# サンプルの製品データ(Note: 動作確認用アプリのためコード内にベタ書き)
PRODUCTS = [
    {"id": 1, "name": "Wireless Mouse", "price": 1000},
    {"id": 2, "name": "Mechanical Keyboard", "price": 18900},
    {"id": 3, "name": "USB-C Hub", "price": 4900}
]

# アプリ起動時刻(JST)
START_TIME = datetime.now(JST)

@app.route("/health", methods=["GET"])
def health():
    """
    ヘルスチェック用エンドポイント
    """
    now_jst = datetime.now(JST)
    uptime_seconds = (now_jst - START_TIME).total_seconds()

    return jsonify({
        "status": "ok",
        "timestamp": now_jst.isoformat(),
        "uptime_seconds": int(uptime_seconds)
    }), 200

@app.route("/products", methods=["GET"])
def list_products():
    """
    製品情報を常に全件返却するエンドポイント
    """
    return jsonify(PRODUCTS), 200

Docker イメージ作成用にrequirements.txtDockerfileも作成しておきます。

Flask>=2.2
gunicorn>=20.1
FROM python:3.11-slim

RUN groupadd -r appuser && useradd -r -g appuser appuser

WORKDIR /app

COPY requirements.txt ./
RUN pip install --no-cache-dir --upgrade pip \
    && pip install --no-cache-dir -r requirements.txt

COPY app.py ./

RUN chown -R appuser:appuser /app
USER appuser

EXPOSE 8000

ENTRYPOINT ["gunicorn"]
CMD ["-w", "1", "-b", "0.0.0.0:8000", "--access-logfile", "-", "--error-logfile", "-", "app:app"]

AWS 資源の構築

CDK を用いて、ALB 及び ECS 資源を構築します。[1][2]

  • defaultActionsjwt-validationを指定して、JWT 検証を設定しています。また、additionalClaims を指定して aud クレーム(JWT が、何/誰を対象として発行されたのかを示すもの)を検証します。
  • CDK 実装のポイントは Mixins を利用している点です。Mixins とは 2025 年 11 月に Developers Preview となった 新機能であり、必要な機能だけを選択して L1/L2/カスタムコンストラクトに型安全に適用できます。 本コードでは L2 コンストラクトを利用しつつ、型安全性を担保しながら L1 プロパティを適用しています。(エスケープハッチは型安全性なし)ただし、本記事執筆時点では Developers Preview であり、今後仕様が変更される可能性に注意してください。
  • なお、ALB のアウトバウンド SG 許可を通じて、jwksEndpoint へアクセスできるように設定してください。(筆者は初期構築時にアウトバウンド SG 許可設定を失念しており、500 Internal Server Errorが ALB から返却されてしまいました。)
new CfnListenerPropsMixin({
  defaultActions: [
    {
      type: "jwt-validation",
      jwtValidationConfig: {
        issuer: props.issuer,
        jwksEndpoint: props.jwksEndpoint,
        additionalClaims: [{
          format: "single-string",
          name: "aud",
          values: [`https://${props.albDomainName}/`]
        }]
      },
      order: 1
    },
    {
      type: 'forward',
      targetGroupArn: targetGroup.targetGroupArn,
      order: 2
    }
  ]
})
CDKスタック全量
import * as cdk from "aws-cdk-lib";
import { Stack, StackProps } from "aws-cdk-lib";
import { Construct } from "constructs";
import { Networking } from "../../constructs/networking";
import {
  aws_certificatemanager as acm,
  aws_route53 as route53,
  aws_logs as logs,
  aws_elasticloadbalancingv2 as elbv2,
  aws_route53_targets as targets,
  aws_s3 as s3,
  aws_ec2 as ec2,
  aws_ecr as ecr,
  aws_ecs as ecs,
} from "aws-cdk-lib";
import "@aws-cdk/mixins-preview/with";
import { CfnListenerPropsMixin } from "@aws-cdk/mixins-preview/aws_elasticloadbalancingv2/mixins";

export interface JwtAlbSampleStackProps extends StackProps {
  readonly hostedZoneId: string;
  readonly zoneName: string;
  readonly vpcCidr: string;
  readonly ecrRepoArn: string;
  readonly imageTag: string;
  readonly albDomainName: string;
  readonly myIp: string;
  readonly issuer: string;
  readonly jwksEndpoint: string;
}

export class JwtAlbSampleStack extends Stack {
  constructor(scope: Construct, id: string, props: JwtAlbSampleStackProps) {
    super(scope, id, props);
    // ------------ Prerequisites ---------------
    // -------- Lookup Resources outside of CDK
    // ---- ECR
    const ecrRepo = ecr.Repository.fromRepositoryArn(
      this,
      "EcrRepo",
      props.ecrRepoArn
    );

    // ---- Route53
    // Hosted Zone
    const myHostedZone = route53.HostedZone.fromHostedZoneAttributes(
      this,
      "HostedZone",
      {
        hostedZoneId: props.hostedZoneId,
        zoneName: props.zoneName,
      }
    );

    // ------------ Network ---------------
    // Create VPC Resources by using Blea
    // https://github.com/aws-samples/baseline-environment-on-aws/blob/main/usecases/blea-guest-ecs-app-sample/lib/construct/networking.ts
    const networking = new Networking(this, "Networking", {
      vpcCidr: props.vpcCidr,
    });

    // ------------ Storage ---------------
    // ---- S3
    // Bucket for Logging
    const logBucket = new s3.Bucket(this, "LogBucket", {
      enforceSSL: true,
      removalPolicy: cdk.RemovalPolicy.DESTROY,
      autoDeleteObjects: true,
    });

    // ------------ Backend Application ---------------
    // -------- ECS
    // ---- Prerequisites for ECS
    // Create LogGroup for Container
    const containerLogGroup = new logs.LogGroup(this, "ContainerLogGroup", {
      removalPolicy: cdk.RemovalPolicy.DESTROY,
      retention: logs.RetentionDays.ONE_WEEK,
    });
    // Create SG for ECS Service
    const serviceSG = new ec2.SecurityGroup(this, "ServiceSG", {
      vpc: networking.vpc,
      allowAllOutbound: true,
      description: "Security group for ECS service",
    });

    // ---- ECS Cluster
    const cluster = new ecs.Cluster(this, "Cluster", {
      vpc: networking.vpc,
      containerInsightsV2: ecs.ContainerInsights.ENHANCED,
    });

    // ---- Task Definition
    const taskDef = new ecs.FargateTaskDefinition(this, "TaskDef", {
      cpu: 512,
      memoryLimitMiB: 1024,
    });
    taskDef.addContainer("AppContainer", {
      image: ecs.ContainerImage.fromEcrRepository(ecrRepo, props.imageTag),
      logging: ecs.LogDrivers.awsLogs({
        streamPrefix: "my-app",
        logGroup: containerLogGroup,
      }),
      portMappings: [{ containerPort: 8000 }],
    });

    // ---- ECS Service
    const service = new ecs.FargateService(this, "FargateService", {
      cluster,
      vpcSubnets: networking.vpc.selectSubnets({ subnetGroupName: "Private" }),
      securityGroups: [serviceSG],
      taskDefinition: taskDef,
      desiredCount: 1, // Note: 検証用のため1で固定する
      circuitBreaker: {
        enable: true,
      },
    });

    // -------- ALB
    // ---- Prerequisites for ALB
    // Create Certificate for ALB
    const certificate = new acm.Certificate(this, "Certificate", {
      domainName: props.albDomainName,
      validation: acm.CertificateValidation.fromDns(myHostedZone),
    });
    // Create SG for ALB
    const albSg = new ec2.SecurityGroup(this, "AlbSg", {
      vpc: networking.vpc,
      allowAllOutbound: false,
    });
    albSg.connections.allowFrom(
      ec2.Peer.ipv4(props.myIp),
      ec2.Port.tcp(443),
      "Allow HTTPS from My IP"
    );
    albSg.connections.allowTo(
      serviceSG,
      ec2.Port.tcp(8000),
      "Allow HTTP to Service"
    );
    albSg.connections.allowTo(
      ec2.Peer.anyIpv4(),
      ec2.Port.tcp(443),
      "Allow HTTPS to Service"
    );

    // ---- LoadBalancer
    // Create ALB
    const alb = new elbv2.ApplicationLoadBalancer(this, "Alb", {
      vpc: networking.vpc,
      internetFacing: true,
      securityGroup: albSg,
      vpcSubnets: networking.vpc.selectSubnets({
        subnetGroupName: "Public",
      }),
    });
    // Enable Access Log
    alb.logAccessLogs(logBucket, "AlbLog");
    // Add ARecord
    new route53.ARecord(this, "AlbARecord", {
      zone: myHostedZone,
      recordName: props.albDomainName,
      target: route53.RecordTarget.fromAlias(
        new targets.LoadBalancerTarget(alb)
      ),
    });

    // ---- Target Group
    const targetGroup = new elbv2.ApplicationTargetGroup(
      this,
      "EcsTargetGroup",
      {
        vpc: networking.vpc,
        port: 8000,
        protocol: elbv2.ApplicationProtocol.HTTP,
        targets: [service],
        healthCheck: {
          enabled: true,
          path: "/health",
          healthyHttpCodes: "200",
        },
      }
    );

    // ---- Listener
    const jwtListener = alb
      .addListener("JwtListener", {
        port: 443,
        certificates: [certificate],
        defaultAction: elbv2.ListenerAction.forward([targetGroup]),
        open: false,
      })
      .with(
        new CfnListenerPropsMixin({
          defaultActions: [
            {
              type: "jwt-validation",
              jwtValidationConfig: {
                issuer: props.issuer,
                jwksEndpoint: props.jwksEndpoint,
                additionalClaims: [
                  {
                    format: "single-string",
                    name: "aud",
                    values: [`https://${props.albDomainName}/`],
                  },
                ],
              },
              order: 1,
            },
            {
              type: "forward",
              targetGroupArn: targetGroup.targetGroupArn,
              order: 2,
            },
          ],
        })
      );
  }
}

動作検証

まずはアクセストークンを指定せずに ALB にリクエストしてみます。
ALB から 401 エラーが返却されました。

$ curl --request GET \
    --url <アプリケーションのURL>
<html>
<head><title>401 Authorization Required</title></head>
<body>
<center><h1>401 Authorization Required</h1></center>
</body>
</html>

次に Auth0 から 正しい aud クレームを持つアクセストークンを取得したうえで ALB へリクエストしてみます。
ローカル PC で Curl コマンドを実行して、アクセストークンを取得します。その後、アクセストークンをauthorizationヘッダに指定して ALB へリクエスト送信すると、無事に Flask アプリケーションへのアクセスに成功しました。

$ curl --request POST \
  --url <auth0のトークンエンドポイント> \
  --header 'content-type: application/json' \
  --data '{
    "client_id":<クライアントID>,
    "client_secret":<クライアントシークレット>,
    "audience": <API作成時に指定した識別子>,
    "grant_type":"client_credentials"
  }'
<アクセストークン>

$ curl --request GET \
    --url <アプリケーションのURL> \
    --header 'authorization: Bearer <アクセストークン>'
[{"id":1,"name":"Wireless Mouse","price":1000},{"id":2,"name":"Mechanical Keyboard","price":18900},{"id":3,"name":"USB-C Hub","price":4900}]

次に、異なる aud クレームの場合の挙動を確認してみます。
異常系の動作確認用に作成した API に設定した audience を指定してアクセストークンを取得します。その後、アクセストークンをauthorizationヘッダに指定して ALB へリクエスト送信すると、ALB からは 401 が返却されました。期待通りaudクレームの検証が行われていそうです。

$ curl --request POST \
  --url <auth0のトークンエンドポイント> \
  --header 'content-type: application/json' \
  --data '{
    "client_id":<クライアントID>,
    "client_secret":<クライアントシークレット>,
    "audience": <異常系の動作確認用API作成時に指定した識別子>,
    "grant_type":"client_credentials"
  }'
<アクセストークン>

$ curl --request GET \
    --url <アプリケーションのURL> \
    --header 'authorization: Bearer <アクセストークン>'
<html>
<head><title>401 Authorization Required</title></head>
<body>
<center><h1>401 Authorization Required</h1></center>
</body>
</html>

おわりに

Auth0 が発行した JWT を ALB で検証することで、アプリコードに手を入れることなく安全な M2M 通信を実現可能なことについて、実資源構築や動作確認を踏まえて解説しました。
認可処理をアプリから分離し、インフラ(AWS)レイヤで完結させることで、セキュリティと運用性の両立を図れます。認可周りの設計実装はチーム間の責務が曖昧になりがちだと思うので、ALB で検証処理を行うことでシステム開発時のチーム間の責務をシンプルにできる点も魅力的です。
ただし、トークン検証を超えたより細かな権限制御が必要な場合は、アプリケーション側の実装が必要不可避です。仕様に合わせて設計することが重要となります。

参考

https://aws.amazon.com/jp/about-aws/whats-new/2025/11/application-load-balancer-jwt-verification/

https://blog.serverworks.co.jp/alb-jwt-tenant-routing

https://docs.aws.amazon.com/elasticloadbalancing/latest/application/listener-verify-jwt.html

https://auth0.com/docs/ja-jp/get-started/apis/api-settings

https://docs.aws.amazon.com/AWSCloudFormation/latest/TemplateReference/aws-resource-elasticloadbalancingv2-listener.html

https://docs.aws.amazon.com/AWSCloudFormation/latest/TemplateReference/aws-properties-elasticloadbalancingv2-listener-action.html#cfn-elasticloadbalancingv2-listener-action-jwtvalidationconfig

https://docs.aws.amazon.com/AWSCloudFormation/latest/TemplateReference/aws-properties-elasticloadbalancingv2-listener-jwtvalidationconfig.html

https://go-to-k.hatenablog.com/entry/cdk-mixins

脚注
  1. エラー「One action of the following types must be specified: redirect,fixed-response,forward」を回避するために、Mixinsでjwt-validationとforwardの両方を指定しています。 ↩︎

  2. 本ブログでは検証用にCDKでECS資源を定義していますが、実構築時はライフサイクルや資源特性の違いを考慮してecspressoでECS設定を定義・管理することが多いです。 ↩︎

Accenture Japan (有志)

Discussion