
プライベートサブネット内のKeycloakでAmazon Bedrock AgentCore RuntimeのInbound認証(JWT認証)を構成してみた
はじめに
こんにちは、ドライブ練習中のコンサルティング部の神野(じんの)です。
車があると大好きなスーパーに行けて便利ですね。
こんな話はさておき、みなさんはAgentCore RuntimeでJWTを使用したインバウンド認証を活用してAIエージェントを実装していますか?
Runtimeには、IdPが発行したJWTでインバウンド認証を行う仕組みが用意されています。CognitoやAuth0のようなパブリックに到達できるIdPであればすんなり使えるのですが、エンタープライズの現場では社内ネットワークからしか到達できないプライベートなIdPでJWT認証したい、というケースがありますよね・・・
インバウンドのJWT検証はAgentCore Identityサービス側で行われるため、以前はdiscovery URLがパブリックに到達可能である必要がありました。しかし現在は、VPC内にホストしたプライベートなIdPにAgentCore Identityが接続できる機能(private identity provider接続)が提供されています。
というわけで今回は、プライベートサブネットに配置したKeycloak(インターネットから到達不可)を使って、AgentCore RuntimeのInbound Authを実際に構築・検証してみました。
いきなり結論
privateEndpoint 設定を使うことで、プライベートなKeycloakのままマネージドなJWT検証を実現できます。
AgentCore IdentityがVPC Lattice経由でVPC内のKeycloakに到達し、discovery URLを取得してトークンの検証をしてくれます。
ただし下記の2つの制約があるので注意しましょう。
- discovery URLはHTTPS必須(HTTPは非対応)
- IdPの証明書はパブリックに信頼された証明書が必要。プライベートCA証明書の場合は、パブリックACM証明書を付けた内部ALBを前段に置くワークアラウンドが必要
次のセクションから、仕組みと構築手順を深掘りしていきます。
方式の調査
インバウンドJWT検証はどこで行われるのか
公式ドキュメントを確認すると、インバウンドJWT認証について下記の記載があります。
When AgentCore Runtime or AgentCore Gateway receives an inbound request, the authorizer will use the configured discovery URL to fetch the public keys and authorization server endpoint to perform the JWT validation.
https://docs.aws.amazon.com/bedrock-agentcore/latest/devguide/inbound-jwt-authorizer.html
JWTの検証を行うのはRuntimeのコンテナではなく、AgentCore Identityサービス側です。そのためdiscovery URL(OIDCの /.well-known/openid-configuration)にはAWSサービス側からアクセスできる必要があります。
RuntimeをVPCに接続すればいいのでは?と思うかもしれませんが、RuntimeのVPC接続はRuntimeコンテナからのアウトバウンド通信を制御するもので、AgentCore IdentityがプライベートIdPのdiscovery URL・JWKSを取得する経路にはなりません。
VPC connectivity impacts only outbound network traffic from the runtime or tool. Inbound requests to the runtime (such as invocations) are not routed through the VPC and are unaffected by this configuration.
うーん、じゃあプライベートIdPは無理なのか・・・と思いきや、専用のドキュメントがありました。IdP検証経路をプライベートにするには、次に紹介するprivateEndpoint設定を使います。
private identity provider接続
公式ドキュメントに、まさにセルフホストKeycloakを例としたプライベートIdP接続のページがあります。
Amazon Bedrock AgentCore Identity supports connecting to OAuth 2.0 identity providers (IdPs) hosted inside your AWS VPC, such as self-hosted Keycloak, PingFederate, or other OIDC-compliant authorization servers, without exposing them to the public internet.
仕組みとしてはAmazon VPC Latticeのリソースゲートウェイ・リソース設定を使い、AgentCore IdentityからVPC内のIdPエンドポイントへの閉域経路を確立します。接続モードは2つ用意されています。
| モード | 概要 | 向いているケース |
|---|---|---|
| managed Lattice | VPC・サブネット・セキュリティグループを指定するだけで、Latticeリソースの作成・管理はAgentCore側が実施 | 同一アカウント内でシンプルに使いたい場合 |
| self-managed Lattice | Latticeリソースゲートウェイ・リソース設定を自分で作成・管理 | クロスアカウント接続や、接続先のガバナンス可視化が必要な場合 |
managed Latticeの場合、ユーザーが明示的にVPC Latticeのリソースを作成・管理する必要はなく、指定したサブネットにリソースゲートウェイのENIが作成される形になります。今回はこちらを使います。
そして冒頭の結論に書いた通り、制約が2つあります。
Discovery URL must be HTTPS: The IdP's OIDC discovery URL must use HTTPS. HTTP endpoints are not supported.
Private certificates: Your IdP must use a publicly trusted TLS certificate, or you must place an ALB with a public ACM certificate in front of it.
HTTPS必須かつパブリックに信頼された証明書が必要です。HTTPのままでは使えないため、パブリックACM証明書を付けた内部ALBをKeycloakの前段に置く構成にします。ACM証明書の発行にはDNS検証用のドメインが1つ必要になる点にご注意ください(今回はお名前.comで取得したドメインのネームサーバーをRoute 53に委任して使いました)。
構成図は下記のイメージです。

認証フローを時系列にすると下記の流れになります。
前提
検証時の環境は下記の通りです。
| 項目 | 内容 |
|---|---|
| リージョン | us-east-1 |
| Keycloak | 26.3(EC2上のDocker、start-devモード) |
| 接続モード | managed Lattice(managedVpcResource) |
| IaC | AWS CDK(TypeScript) |
| Runtime SDK | bedrock-agentcore(Python 3.12) |
| エージェントフレームワーク | Strands Agents |
| モデル | Claude Haiku 4.5 |
| ドメイン | Route 53に委任済みの独自ドメイン(ACM証明書のDNS検証に使用) |
今回のコード一式は下記リポジトリに置いています。本記事ではポイントだけ抜粋するので、全体はこちらをご確認ください。
構築
CDKアプリは2つのスタックに分けています。Runtime作成時にAgentCore Identityがdiscovery URLへ実際にアクセスして検証するため、Keycloak側が応答可能になってからRuntimeをデプロイする、という順序を担保するためです。
- KeycloakIdpStack — VPC、プライベートKeycloak、TLS終端用の内部ALB
- AgentCoreRuntimeStack — エージェントイメージ、実行ロール、AgentCore Runtime
VPCとプライベートKeycloak + 内部ALBの構築(KeycloakIdpStack)
まずはIdP側のスタックです。Keycloakを動かすEC2にはパブリックIPを付けず、管理はSSMセッションマネージャー経由で行います。スタックで作成する主なリソースは下記の通りです。
- VPC(パブリック + プライベートサブネット×2AZ、NAT Gateway)
- Keycloak EC2(プライベートサブネット、UserDataでdemoレルムを自動インポート)
- ACMパブリック証明書(Route 53ホストゾーンでDNS検証)
- 内部ALB(HTTPS:443でTLS終端 → Keycloak:8080へ転送)
- Route 53エイリアスレコード(keycloakサブドメイン → 内部ALB)
- Latticeリソースゲートウェイ用のセキュリティグループ(後述のprivateEndpoint設定で使用)
- 検証用クライアントEC2(t4g.nano、プライベートサブネット。閉域内クライアント役として動作確認で使用)
設計のポイントを、コードを抜粋しながら説明します。
1つ目はTLS終端です。内部ALBで行い、証明書はACMのパブリック証明書(DNS検証)を使います。
const certificate = new acm.Certificate(this, 'Certificate', {
domainName: props.domainName,
validation: acm.CertificateValidation.fromDns(hostedZone),
});
const alb = new elbv2.ApplicationLoadBalancer(this, 'LoadBalancer', {
vpc: this.vpc,
internetFacing: false, // 内部スキーム
vpcSubnets: { subnetType: ec2.SubnetType.PRIVATE_WITH_EGRESS },
securityGroup: albSecurityGroup,
});
ALBは内部スキームなのでインターネットからは到達できませんが、証明書自体はパブリックに信頼されたものになるため、AgentCore Identityの証明書要件を満たせます。ちなみにDNSレコード(keycloakサブドメイン→内部ALBのエイリアス)はパブリックホストゾーンに作成しています。名前解決は誰でもできますが、返ってくるのはプライベートIPなので到達はできない、という形です。
2つ目はKeycloakのホスト名まわりです。EC2のUserDataでKeycloakコンテナを起動する際、下記の環境変数を渡しています。
`-e KC_HOSTNAME=https://${props.domainName}`,
'-e KC_HTTP_ENABLED=true',
'-e KC_PROXY_HEADERS=xforwarded',
KC_HOSTNAMEにHTTPSのドメインURLを固定で設定することで、issuerが https://keycloak.<ドメイン>/realms/demo で一定になります。TLSはALBで終端するので、Keycloak自体はHTTPのまま KC_PROXY_HEADERS=xforwarded でX-Forwardedヘッダーを信頼させる構成です。
3つ目はLatticeリソースゲートウェイ用のセキュリティグループです。AgentCoreが作成するENIに付与するため、このスタックで先に作っておき、後ほどRuntime作成時のprivateEndpoint設定で指定します。
// AgentCoreが作成するLatticeリソースゲートウェイENIに付与するSG
this.latticeSecurityGroup = new ec2.SecurityGroup(this, 'LatticeSecurityGroup', {
vpc: this.vpc,
description: 'Security group for AgentCore managed Lattice resource gateway ENIs',
});
albSecurityGroup.addIngressRule(
ec2.Peer.ipv4(this.vpc.vpcCidrBlock),
ec2.Port.tcp(443),
'HTTPS from within VPC (Lattice gateway ENIs / clients)',
);
ALBのセキュリティグループはVPC内からの443のみ許可としており、インターネットからは到達できません(VPC内の他リソースからは到達可能な設定なので、より厳密に絞る場合はLatticeSecurityGroupからのインバウンドのみに限定してください)。
もう1点、VPCのAZには注意が必要です。AgentCoreのVPC接続は利用できるAZが決まっている(us-east-1では use1-az1 / use1-az2 / use1-az4)ため、対応AZを明示指定しています。
this.vpc = new ec2.Vpc(this, 'Vpc', {
ipAddresses: ec2.IpAddresses.cidr('10.0.0.0/16'),
availabilityZones: ['us-east-1a', 'us-east-1b'], // AgentCore対応AZを明示指定
natGateways: 1,
...
});
なお、use1-az1 のようなAZ IDと us-east-1a のようなAZ名の対応はAWSアカウントごとに異なります。上記のAZ名は私のアカウントでの対応例なので、下記のコマンドなどでご自身のアカウントの対応を確認してから指定してください。
aws ec2 describe-availability-zones --region us-east-1 \
--query 'AvailabilityZones[].{Name:ZoneName,Id:ZoneId}' --output table
realmとクライアント(client_credentialsフロー用のconfidential client)はrealmインポート機能で起動時に自動作成しています。audience-mapperでアクセストークンのaudクレームにagentcore-runtimeを追加しており、これが後のauthorizer設定のallowedAudienceと対応します。
ドメインやシークレットはcdk.jsonのcontextで指定して、デプロイします。
| context項目 | 説明 |
|---|---|
| domainName | Keycloakに割り当てるドメイン(例: keycloak.example.com) |
| hostedZoneId / hostedZoneName | DNS検証・レコード作成に使うRoute 53パブリックホストゾーン |
| keycloakClientSecret | demoクライアントのシークレット(検証用) |
cd cdk
pnpm install
pnpm exec cdk deploy KeycloakIdpStack
デプロイ完了後、VPC内とローカル(インターネット側)の両方からdiscovery URLを叩いて、プライベート性を確認してみます。
VPC内からの実行には、閉域内クライアント役として用意した検証用クライアントEC2を使います。パブリックIPなし・SSHキーなしなので、SSMセッションマネージャーでログインし、その中でcurlを実行する形です(以降の「SSMセッション内で実行」のコマンドも同様です)。
INSTANCE_ID=$(aws ec2 describe-instances \
--filters "Name=tag:Name,Values=keycloak-test-client" \
"Name=instance-state-name,Values=running" \
--query 'Reservations[0].Instances[0].InstanceId' --output text)
aws ssm start-session --target ${INSTANCE_ID}
ログインしたら、Keycloakのdiscovery URLをcurlで叩いてみます。
curl -s https://keycloak.<ドメイン>/realms/demo/.well-known/openid-configuration
{"issuer":"https://keycloak.<ドメイン>/realms/demo",
"token_endpoint":"https://keycloak.<ドメイン>/realms/demo/protocol/openid-connect/token", ...
同じコマンドを手元のマシン(インターネット側)からタイムアウト付きで実行すると、こちらは応答が返ってきません。
curl -s --max-time 8 -o /dev/null -w "local_access: %{http_code} %{errormsg}\n" \
https://keycloak.<ドメイン>/realms/demo/.well-known/openid-configuration
local_access: 000 Connection timed out after 8002 milliseconds
VPC内からはHTTPS + 正規証明書で応答し、インターネットからはタイムアウトしていますね!ちゃんとプライベートなIdPになっています。
エージェントの実装
エージェント側はJWT検証を書く必要がありません。検証はAgentCore Identityが済ませてくれるので、エージェントは認証済みのリクエストだけを受け取ります。また、Runtime作成時の設定(requestHeaderConfigurationのrequestHeaderAllowlist。次のAgentCoreRuntimeStackのセクションで登場します)でAuthorizationヘッダーを許可しておくと、検証済みトークンがそのままエージェントへ転送されてくるので、今回はclaimsの中身を応答に含めて確認できるようにしました。
import base64
import json
from bedrock_agentcore import BedrockAgentCoreApp, RequestContext
app = BedrockAgentCoreApp()
def decode_claims(token: str) -> dict:
"""表示用にJWTペイロードをデコードする(検証はAgentCore Identity側で完了済み)"""
payload = token.split(".")[1]
payload += "=" * (-len(payload) % 4)
return json.loads(base64.urlsafe_b64decode(payload))
@app.entrypoint
def invoke(payload, context: RequestContext):
headers = {k.lower(): v for k, v in (context.request_headers or {}).items()}
auth_header = headers.get("authorization", "")
claims = decode_claims(auth_header.removeprefix("Bearer ")) if auth_header else {}
app.logger.info("Invoked. client=%s", claims.get("azp"))
prompt = payload.get("prompt", "こんにちは")
try:
from strands import Agent
agent = Agent(model="us.anthropic.claude-haiku-4-5-20251001-v1:0")
response = str(agent(prompt))
except Exception as e:
response = f"(モデル呼び出し不可: {e})"
return {
"authenticated_client": claims.get("azp"),
"issuer": claims.get("iss"),
"audience": claims.get("aud"),
"response": response,
}
app.run()
RequestContextのrequest_headersから転送されたAuthorizationヘッダーを取り出しています。デコードしているのは表示用で、署名検証はこのコードに到達する前にAWS側で完了しています。
AgentCore Runtimeにはlinux/arm64のコンテナイメージが必要ですが、ここはCDKのDockerImageAssetに任せています。
const image = new ecrAssets.DockerImageAsset(this, 'AgentImage', {
directory: '../agent',
platform: ecrAssets.Platform.LINUX_ARM64,
});
次のスタックのデプロイ時にarm64でのビルドからECRへのプッシュまで自動で行われるので、手動のdocker buildやECR操作は不要です。
AgentCore RuntimeにprivateEndpoint付きauthorizerを設定(AgentCoreRuntimeStack)
customJWTAuthorizerにprivateEndpointを含めてRuntimeを作成します。設定の中身はスタック内で下記のように組み立てています。
requestHeaderConfiguration: {
requestHeaderAllowlist: ['Authorization'],
},
authorizerConfiguration: {
customJWTAuthorizer: {
discoveryUrl: props.discoveryUrl,
allowedAudience: ['agentcore-runtime'],
privateEndpoint: {
managedVpcResource: {
vpcIdentifier: props.vpc.vpcId,
subnetIds: props.vpc.privateSubnets.map((s) => s.subnetId),
endpointIpAddressType: 'IPV4',
securityGroupIds: [props.latticeSecurityGroup.securityGroupId],
},
},
},
},
| 設定項目 | 設定値 | 説明 |
|---|---|---|
| discoveryUrl | KeycloakのプライベートなOIDC discovery URL | HTTPS必須 |
| allowedAudience | agentcore-runtime | JWTのaudクレームと照合される |
| privateEndpoint | managedVpcResource | AgentCore IdentityがVPC Lattice経由でdiscovery URLへ到達する |
| securityGroupIds | Lattice用SG | リソースゲートウェイのENIに付与される |
| networkMode | PUBLIC | インバウンド検証はprivateEndpoint経由なので、Runtime自体のVPC接続は不要 |
| requestHeaderAllowlist | Authorization | 検証済みJWTをエージェントコードへ転送 |
RuntimeのnetworkModeはPUBLICのままでもOKです。プライベートに閉じる必要があるのはAgentCore Identity→Keycloakの検証経路であって、それはprivateEndpointが担ってくれます。エージェント自身がVPC内リソースへアクセスする場合は別途VPCモードにします。
デプロイします。
pnpm exec cdk deploy AgentCoreRuntimeStack
作成すると、AgentCoreがサービスリンクロール AWSServiceRoleForBedrockAgentCoreIdentity を作成し、指定したサブネットにLatticeリソースゲートウェイのENIを作ってくれます。ENIを確認してみると下記のように作成されていました。
interface Resource Gateway Interface rgw-0fed0e24083ed2423 in-use 10.0.10.205
interface Resource Gateway Interface rgw-0fed0e24083ed2423 in-use 10.0.11.105
動作確認
環境が作れたので実際に試してみます!
トークンの取得
クライアントは閉域内からKeycloakに到達できる想定なので、検証では前述のSSMセッションマネージャーで検証用クライアントEC2にログインし、その中でclient_credentialsフローのトークンを取得します。後続の呼び出しでも使うので、シェル変数に格納しておきます。
クライアントシークレットは、cdk.jsonのcontext(keycloakClientSecret)で指定した値です。
ACCESS_TOKEN=$(curl -s -X POST https://keycloak.<ドメイン>/realms/demo/protocol/openid-connect/token \
-d grant_type=client_credentials \
-d client_id=agentcore-client \
-d client_secret=<クライアントシークレット> \
| python3 -c "import sys, json; print(json.load(sys.stdin)['access_token'])")
echo ${ACCESS_TOKEN}
取得できるアクセストークンは eyJhbGciOiJSUzI1NiIs... のような、ヘッダー・ペイロード・署名をドットで連結したJWT形式の文字列です。ペイロード部分をBase64デコードすると中身のclaimsを確認できます。
python3 -c "import base64, json, sys; p = sys.argv[1].split('.')[1]; \
print(json.dumps(json.loads(base64.urlsafe_b64decode(p + '=' * (-len(p) % 4))), indent=2))" "${ACCESS_TOKEN}"
デコード結果のうち、今回の認証に関係するclaimsを抜粋したのが下記です(exp・scopeなどその他のclaimsは省略しています)。
{
"iss": "https://keycloak.<ドメイン>/realms/demo",
"aud": ["agentcore-runtime", "account"],
"azp": "agentcore-client"
}
issuerがプライベートなKeycloakのURL、audにはaudience-mapperで付与した agentcore-runtime が入っていて、設定通りトークンが払い出されましたね!
エージェントの呼び出し
AuthorizationヘッダーのBearerトークンをつければ呼び出せます。データプレーンのエンドポイントはパブリックなので、curlで呼び出し可能です。
ARN_ENC=$(python3 -c "import urllib.parse; print(urllib.parse.quote('YOUR_RUNTIME_ARN', safe=''))")
curl -s -X POST \
"https://bedrock-agentcore.us-east-1.amazonaws.com/runtimes/${ARN_ENC}/invocations?qualifier=DEFAULT" \
-H "Authorization: Bearer ${ACCESS_TOKEN}" \
-H "Content-Type: application/json" \
-H "X-Amzn-Bedrock-AgentCore-Runtime-Session-Id: <33文字以上のセッションID>" \
-d '{"prompt": "AgentCore Identityについて一言で教えて"}'
{
"authenticated_client": "agentcore-client",
"issuer": "https://keycloak.<ドメイン>/realms/demo",
"audience": ["agentcore-runtime", "account"],
"response": "# AgentCore Identity について\n\n..."
}
お、認証が通ってエージェントの応答が返ってきましたね!インターネットから到達できないKeycloakが発行したJWTを、AgentCore IdentityがVPC Lattice経由でJWKSを取得して検証してくれています。無事プライベートKeycloakのままマネージドなJWT認証ができました!よかった!!
異常系の確認
認証として機能しているか、不正なリクエストも念のため簡単に確認しておきます。まずは署名部分を改ざんしたトークンです。
{"jsonrpc":"2.0","error":{"code":-32001,"message":"Invalid Signature for bearerToken"}}
HTTP_STATUS: 401
続いてトークンなしの場合です。
{"jsonrpc":"2.0","error":{"code":-32001,"message":"Missing Authentication Token"}}
HTTP_STATUS: 401
どちらも401で拒否されていますね!
おわりに
今回はclient_credentialsフローのM2M認証でしたが、ユーザーのアクセストークンを使って検証ももちろん可能です!
プライベートなIdPを使用したい場合にprivateEndpoint機能を活用していきたいですね。
本記事が少しでも参考になりましたら幸いです。最後までご覧いただきありがとうございました!
補足
managed Latticeのコスト
managed Latticeモードでは、リソースゲートウェイを通過するデータ処理量に応じたVPC Latticeの課金が発生します。JWKSの取得はキャッシュも使用するため検証用途ではごくわずかな想定ですが、料金体系の詳細は公式のVPC Lattice料金ページをご確認ください。





