x402プロトコルをAWS公式サンプルから読み解く — Lambda@Edgeで402を返す実装の中身
前回の AgentCore Payments の記事 では、x402 プロトコルの存在には触れたものの、中身がどう動くかには踏み込めませんでした。発表と同時に AWS が出してきた 公式サンプル (aws-samples/sample-agentcore-cloudfront-x402-payments) がプロダクション品質で書かれていて、x402 の中身を理解するには最高の教材になっていたので、コードを読んで分かったことを整理しておきます。
このサンプルを読むと 「402 を返す側」と「402 を払う側」の両方の実装パターン が一気に分かります。CloudFront + Lambda@Edge で売り手を構築し、AgentCore + Strands で買い手エージェントを動かす構成です。x402 をオレオレ実装する前に、まずこのリファレンス実装を読んでおくのが圧倒的に効率が良いと感じました。
検証環境
実機デプロイは料金が発生するのと、AgentCore Payments が東京リージョン未対応なので、今回はサンプルリポを clone してコードを読むレベルで進めます。
- リポジトリ:
aws-samples/sample-agentcore-cloudfront-x402-payments(2026年5月時点) - AWS CLI:
aws-cli/2.34.46(bedrock-agentcore/bedrock-agentcore-controlのpayment系サブコマンドが利用可能) - ローカル環境: macOS, Node.js 18+, Python 3.10+
git clone https://github.com/aws-samples/sample-agentcore-cloudfront-x402-payments
cd sample-agentcore-cloudfront-x402-payments
サンプル全体は5つのスタックに分かれています。
| スタック | 役割 | 主要技術 |
|---|---|---|
seller-infrastructure | 売り手側(402を返す) | CloudFront + Lambda@Edge + S3 |
payer-infrastructure | 買い手側のインフラ(IAM/CloudWatch) | CDK + AgentCore IAM Role |
payer-agent | 買い手エージェントのコード | Strands + AgentCore Payments SDK |
web-ui | 動作確認用UI | React + Vite |
web-ui-infrastructure | UIのデプロイ用 | CDK |
このうち本記事で読むのは、売り手(seller-infrastructure)と買い手(payer-agent + payer-infrastructure)です。Web UI は本筋ではないので割愛します。
x402プロトコルの動作フロー
具体的な実装に入る前に、プロトコル全体の流れを押さえておきます。前回記事より一歩踏み込んだフローです。
1. Client ──────GET /api/premium-article──────────────→ Server
2. Client ←────402 + X-PAYMENT-REQUIRED (base64 JSON)── Server
3. Client → Sign EIP-3009 authorization
4. Client ──────POST /api/premium-article─────────────→ Server
+ PAYMENT-SIGNATURE (base64 JSON)
5. Server ──/verify──→ Facilitator (https://www.x402.org/facilitator)
6. Server ←──valid──── Facilitator
7. Server ──/settle──→ Facilitator → on-chain tx
8. Server ←──tx hash── Facilitator
9. Client ←────200 + X-PAYMENT-RESPONSE (settlement)─── Server
ポイントは、Facilitator が verify と settle の2段に分かれていることと、署名は EIP-3009 の transferWithAuthorization に従う点です。Server 側はオンチェーン処理を Facilitator に丸投げでき、ブロックチェーンインフラを持つ必要がありません。これがプロトコルの実用性を支えています。
公式ドキュメントには以下のように記載されています(2026年5月時点 AWS Documentation)。
The x402 protocol is an open, HTTP-native payment standard that repurposes the HTTP 402 status code for direct, programmatic payments. When an agent requests a paid resource, the merchant responds with HTTP
402 Payment Requiredincluding a payment payload that specifies the amount, recipient, asset, and network. The agent signs the payment and retries the request with the signed proof in theX-PAYMENTheader.
ヘッダ名はバージョンで微妙に違っていて、サンプルは v2 を使っています(後で詳しく)。
scheme exact on EVM の仕組み
x402 には複数の scheme(決済方式)がありますが、サンプルが使っているのは scheme exact on EVM です。コインベースの仕様書には3方式が定義されています(2026年5月時点 coinbase/x402 spec)。
The exact scheme enables gasless transactions where the facilitator pays gas costs while the user maintains cryptographic control over fund transfers. The specification supports three asset transfer methods:
- EIP-3009: Tokens with native
transferWithAuthorization(recommended for USDC-like tokens)- Permit2: Universal fallback for any ERC-20 using a proxy contract
- ERC-7710: Smart account delegation method
サンプルは EIP-3009 ベース(USDC が native でサポート)です。これがどういう意味かを噛み砕くと:
- ガス代を Facilitator が払う: 買い手はガス代の心配をしなくて良い。Facilitator が代わりに支払う
- 署名は買い手が完全コントロール: 秘密鍵を Facilitator に渡すわけではなく、買い手が EIP-3009 形式の
transferWithAuthorizationを署名するだけ - 署名は時間制限付き:
validAfter/validBeforeで有効期間を縛れる - 二重実行を防ぐ nonce: 同じ署名を二度使えない
EIP-3009 の authorization 構造は、サンプルの seller-infrastructure/lib/lambda-edge/types.ts で型として定義されています。
/**
* EIP-3009 Authorization structure
*/
export interface Authorization {
from: string;
to: string;
value: string;
validAfter: string;
validBefore: string;
nonce: string;
}
/**
* Exact EVM payment payload
*/
export interface ExactEvmPayload {
signature: string;
authorization: Authorization;
}
signature は65バイト(EOA署名)または可変長(スマートウォレット署名)、nonce は32バイトのランダム値です。これらは買い手側で生成して X-PAYMENT ヘッダに埋め込みます。
売り手側の実装 — Lambda@Edgeで402を返す
サンプルの一番の見どころが、CloudFront の Origin Request で動く Lambda@Edge です。seller-infrastructure/lib/lambda-edge/payment-verifier.ts がその実装で、約880行ありますが、ロジックの骨格はシンプルです。
全体の制御フロー
export const handler = async (
event: CloudFrontRequestEvent
): Promise<CloudFrontRequestResult> => {
const request = event.Records[0].cf.request;
const uri = request.uri;
// 1. /mcp/tools は MCP discovery 用 — 課金なし
if (uri === '/mcp/tools') {
return createMCPDiscoveryResponse(requestId);
}
// 2. このパスは課金対象か?
const paymentRequirement = contentManager.getPaymentRequirements(uri);
if (!paymentRequirement) {
return request; // 課金不要 → そのまま origin へ
}
// 3. X-PAYMENT-SIGNATURE ヘッダはあるか?
const paymentSignatureHeader =
request.headers['x-payment-signature'] ||
request.headers['payment-signature'];
if (!paymentSignatureHeader || !paymentSignatureHeader[0]) {
return create402Response(uri, paymentRequirement); // 4. 402 を返す
}
// 5. payload base64 デコード → JSON
const paymentPayload = JSON.parse(
Buffer.from(paymentPayloadBase64, 'base64').toString('utf-8')
);
// 6. ペイロード構造の検証(型レベル)
if (!validatePayloadStructure(paymentPayload)) {
return create402Response(uri, paymentRequirement, 'Invalid structure');
}
// 7. パラメータ検証(scheme/network/recipient/amount/期限/asset/署名形式)
const paramValidation = validateAuthorizationParameters(...);
if (!paramValidation.isValid) {
return create402Response(uri, paymentRequirement, `Failed: ${reason}`);
}
// 8. Facilitator /verify
const signatureValidation = await verifySignatureWithFacilitator(...);
if (!signatureValidation.isValid) {
return create402Response(uri, paymentRequirement, 'Signature invalid');
}
// 9. Facilitator /settle(オンチェーン送金)
const settlement = await settlePaymentWithFacilitator(...);
if (!settlement.success) {
return createErrorResponse('402', ...);
}
// 10. コンテンツ返却(X-PAYMENT-RESPONSE で settlement 情報を伝える)
return {
status: '200',
headers: {
'x-payment-response': [{ value: base64(settlementResponse) }],
...
},
body: JSON.stringify(content),
};
};
ここで重要な設計判断が見えます。
Facilitator を呼ぶ前に8段階の事前検証をかけている
validateAuthorizationParameters 関数で、Facilitator にネットワーク呼び出しを投げる前に、ローカルで判定可能な検証を全部済ませています。これは設計として正しいです。Facilitator コールはオンチェーン検証を含むので時間がかかるし、x402.org の Facilitator は無料枠を超えると課金が発生するので、明らかにダメな署名で消費したくない。
// scheme が合っているか
if (payload.accepted.scheme !== requirements.scheme) {
return { isValid: false, invalidReason: 'scheme_mismatch' };
}
// network が合っているか(例: eip155:84532 = Base Sepolia)
if (payload.accepted.network !== requirements.network) {
return { isValid: false, invalidReason: 'network_mismatch' };
}
// 受取人アドレスが正しいか(lowercase比較)
if (authorization.to.toLowerCase() !== requirements.payTo.toLowerCase()) {
return { isValid: false, invalidReason: 'invalid_exact_evm_payload_recipient_mismatch' };
}
// 金額が十分か(BigInt比較)
const paymentValue = BigInt(authorization.value);
const requiredAmount = BigInt(requirements.amount);
if (paymentValue < requiredAmount) {
return { isValid: false, invalidReason: 'invalid_exact_evm_payload_authorization_value' };
}
// 期限が現在時刻より前ではないか(validAfter ≤ now)
const now = Math.floor(Date.now() / 1000);
const validAfter = parseInt(authorization.validAfter, 10);
if (validAfter > now) {
return { isValid: false, invalidReason: 'invalid_exact_evm_payload_authorization_valid_after' };
}
// 期限が切れていないか(validBefore > now + 6秒)
const validBefore = parseInt(authorization.validBefore, 10);
if (validBefore < now + 6) {
return { isValid: false, invalidReason: 'invalid_exact_evm_payload_authorization_valid_before' };
}
// asset アドレスが合っているか
if (payload.accepted.asset.toLowerCase() !== requirements.asset.toLowerCase()) {
return { isValid: false, invalidReason: 'asset_mismatch' };
}
// 署名形式(0xから始まる、130 hex chars以上)
if (!signature.startsWith('0x') || signature.length - 2 < 130) {
return { isValid: false, invalidReason: 'invalid_signature_format' };
}
// nonce形式(32バイト = 66 chars = '0x' + 64 hex chars)
if (!nonce.startsWith('0x') || nonce.length !== 66) {
return { isValid: false, invalidReason: 'invalid_nonce_format' };
}
特に validBefore < now + 6 の6秒バッファ が個人的にツボでした。これは「ブロックタイム分のバッファ」で、署名の有効期限がブロック確定までの間に切れないよう余裕を見ています。こういう細かい配慮がリファレンス実装の質を上げています。
402レスポンスのヘッダ設計
create402Response 関数で、402 を返すときのヘッダ構造が見られます。
function create402Response(
uri: string,
requirements: PaymentRequirements,
errorMessage?: string
): CloudFrontRequestResult {
const paymentRequired: PaymentRequired = {
x402Version: 2,
error: errorMessage || 'Payment required to access this resource',
resource: {
url: uri,
description: `Protected resource at ${uri}`,
mimeType: 'application/json',
},
accepts: [requirements],
extensions: {},
};
return {
status: '402',
headers: {
'x-payment-required': [{
key: 'X-PAYMENT-REQUIRED',
value: Buffer.from(JSON.stringify(paymentRequired)).toString('base64'),
}],
// CORS 設定
'access-control-expose-headers': [{
key: 'Access-Control-Expose-Headers',
value: 'X-PAYMENT-REQUIRED, X-PAYMENT-RESPONSE',
}],
...
},
body: JSON.stringify({ error: 'Payment Required', ... }),
};
}
ポイントは:
- 支払い要件は
X-PAYMENT-REQUIREDヘッダ(base64エンコードされたJSON)に入っている。これは x402 v2 の仕様 - body にも JSON が入っている。v1 互換のため
Access-Control-Expose-Headersを明示しないと、ブラウザのCORS制約でクライアントがX-PAYMENT-REQUIREDを読めない
X-PAYMENT-REQUIRED の中身はこんな JSON です。
{
"x402Version": 2,
"error": "Payment required to access this resource",
"resource": {
"url": "/api/premium-article",
"description": "Protected resource at /api/premium-article",
"mimeType": "application/json"
},
"accepts": [{
"scheme": "exact",
"network": "eip155:84532",
"amount": "1000",
"asset": "0x036CbD53842c5426634e7929541eC2318f3dCF7e",
"payTo": "0x24842F3136Fa2a3df835d36b4c3cb4972d405502",
"maxTimeoutSeconds": 60,
"extra": {
"name": "USDC",
"version": "2",
"assetTransferMethod": "eip3009"
}
}],
"extensions": {}
}
注目したいのは accepts が配列になっていることです。これは「売り手は複数の支払い方法を受け入れられる」ことを意味します。例えば「USDC でも JPYC でも OK」みたいな複数選択肢を提示できる。今回のサンプルでは1つだけですが、設計上は拡張性があります。
Facilitator を呼ぶ部分
検証ロジックの最後に、実際にオンチェーン処理を行う Facilitator を呼びます。サンプルでは https://www.x402.org/facilitator をハードコードしています(Lambda@Edge は環境変数非対応のため)。
const FACILITATOR_URL = 'https://www.x402.org/facilitator';
async function verifySignatureWithFacilitator(
payload: PaymentPayload,
requirements: PaymentRequirements,
logger: Logger
): Promise<VerifyResponse> {
const response = await fetch(`${FACILITATOR_URL}/verify`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
paymentPayload: payload,
paymentRequirements: requirements,
}),
});
if (!response.ok) {
return { isValid: false, invalidReason: 'facilitator_verification_failed', payer };
}
return await response.json() as VerifyResponse;
}
/verify で署名の正当性を確認し、/settle で実際にトークンを動かす2段構成です。/verify は単に署名検証だけ(チェーンに書き込まない)なので高速、/settle でオンチェーン送金が走ってトランザクションハッシュが返ってきます。
これが分離されている理由として、Facilitator 側が「verify した後に何か別の判断(不正検知、KYC等)を挟みたい」場合や、「verifyだけしてsettleを別タイミングで実行したい」ようなユースケースに対応できる柔軟性を持たせるためだと思います。
Lambda@Edgeを使う設計判断
このサンプルの面白いところは、API Gateway + Lambda ではなく CloudFront + Lambda@Edge を使っていることです。理由は3つあると思います。
- エッジで弾けば origin に負荷をかけない。署名がない・期限切れのリクエストは全てエッジで 402 を返せる
- グローバル分散。世界中のエージェントから低レイテンシで支払いリクエストを受けられる
- S3 + CloudFront という典型構成にそのまま乗せられる。既存のコンテンツ配信パイプラインに「課金レイヤー」を後付けする発想
ただし Lambda@Edge には制約もあって、例えば環境変数が使えない(コードに定数として埋め込む必要がある)、サイズ制限がきつい、デプロイに数分かかる、というデメリットもあります。サンプルが FACILITATOR_URL をハードコードしているのもこの制約のためです。
コンテンツと価格の設定
seller-infrastructure/lib/lambda-edge/content-config.ts に、サンプルが提供するコンテンツとその価格設定がまとまっています。
const DEFAULT_NETWORK = 'eip155:84532'; // Base Sepolia testnet
const DEFAULT_ASSET = '0x036CbD53842c5426634e7929541eC2318f3dCF7e'; // USDC on Base Sepolia
const DEFAULT_PAY_TO = '0x24842F3136Fa2a3df835d36b4c3cb4972d405502';
export function createPaymentRequirements(
amount: string,
overrides?: Partial<PaymentRequirements>
): PaymentRequirements {
return {
scheme: 'exact',
network: DEFAULT_NETWORK,
amount,
asset: DEFAULT_ASSET,
payTo: DEFAULT_PAY_TO,
maxTimeoutSeconds: 60,
extra: {
name: 'USDC',
version: '2',
assetTransferMethod: 'eip3009',
},
...overrides,
};
}
amount はUSDC の最小単位(atomic units)の文字列で、USDC は6 decimals なので 1000 = 0.001 USDC です。extra.assetTransferMethod で eip3009 を明示しているのが分かります。
各エンドポイントの価格は次のように設定されています。
| エンドポイント | 価格 (atomic) | 価格 (USDC) | 種別 |
|---|---|---|---|
/api/weather-data | 500 | 0.0005 | 動的生成(天気) |
/api/premium-article | 1000 | 0.001 | inline JSON |
/api/market-analysis | 2000 | 0.002 | 動的生成(市況) |
/api/tutorial | 3000 | 0.003 | S3バックエンド |
/api/research-report | 5000 | 0.005 | S3バックエンド |
/api/dataset | 10000 | 0.01 | S3バックエンド |
ContentSource という型で、コンテンツのソースを inline / S3 / dynamic の3種類から選べる設計になっています。
export type ContentSource =
| { type: 'inline'; data: unknown }
| { type: 's3'; bucket: string; key: string }
| { type: 'dynamic'; generator: string };
これは実運用で広がる柔軟性です。たとえば「リアルタイム市況」みたいな動的生成コンテンツも、「過去レポート」みたいなS3静的コンテンツも、同じ x402 価格レイヤーで扱える。
買い手側 — エージェントは3ステップで支払う
ここから買い手エージェントの実装に入ります。これがプロトコル理解の上で一番面白いところです。
サンプルの payer-agent/agent/main.py で、エージェントのシステムプロンプトに 3ステップの支払いフロー が明示的に書かれています。
SYSTEM_PROMPT = """You are an AI payment agent that helps users access paid services using the x402 protocol.
You process payments via Amazon Bedrock AgentCore Payments — a managed service that handles
wallet management and transaction signing server-side. You operate under ProcessPaymentRole
with a pre-set session budget and CANNOT create sessions, instruments, or override limits.
...
## Payment Flow (Three Steps)
When an endpoint returns HTTP 402 (Payment Required):
1. **Detect 402**: Call request_content(url) or request_service(name).
The response includes x402_payload and x402_version.
2. **Pay**: Call process_payment(x402_payload=<the x402_payload>, x402_version=<version>).
Pass x402_payload AS-IS — do not reconstruct or cherry-pick fields.
Wait for status: "PROOF_GENERATED".
3. **Retry**: Call request_content_with_payment(url) or request_service(name) again.
The payment proof is automatically attached. Includes retry with backoff
for on-chain settlement.
That's it — three tool calls.
"""
「3つのツール呼び出しで終わり」と明示しているのが教育的です。Claude 4.7 等のモデルがこのシステムプロンプトを読んで、適切にツールを使い分ける、という設計。
Step 1: 402 を検知する(request_content)
payer-agent/agent/tools/content.py の request_content ツールが、URL を叩いて 402 をパースします。
@tool
def request_content(url: str) -> dict[str, Any]:
"""Request content from the seller API.
If the endpoint returns HTTP 402 (Payment Required), the x402 payment
requirements are extracted and returned as x402_payload.
"""
full_url = f"{config.seller_api_url}{url}"
with httpx.Client(timeout=30.0) as client:
response = client.get(full_url, headers={"Accept": "application/json"})
if response.status_code == 200:
return {"http_status": 200, "data": response.json()}
if response.status_code == 402:
# v2: PAYMENT-REQUIRED ヘッダ(base64エンコードされたJSON)
pr_header = (
response.headers.get("PAYMENT-REQUIRED")
or response.headers.get("x-payment-required")
or response.headers.get("X-PAYMENT-REQUIRED")
)
if pr_header:
payment_info = json.loads(base64.b64decode(pr_header))
x402_version = payment_info.get("x402Version", 2)
# accepts[0] を生ペイロードとして取り出す
accepts = payment_info.get("accepts", [])
x402_payload = accepts[0]
return {
"http_status": 402,
"x402_payload": x402_payload,
"x402_version": x402_version,
"message": "Payment required. Pass x402_payload directly to process_payment..."
}
ポイントは accepts[0] をそのまま x402_payload として返す こと。エージェントには「フィールドを個別に解釈するな、まるごと次のツールに渡せ」と指示しています。これは LLM が変な部分パースをして壊さないようにするための設計判断です。
Step 2: 支払いを実行する(process_payment)
payer-agent/agent/tools/payment.py の process_payment ツールが、AgentCore Payments の API を呼びます。
@tool
def process_payment(x402_payload: dict, x402_version: int = 1) -> dict[str, Any]:
"""Execute an x402 crypto payment via AgentCore Payments ProcessPayment API.
Pass the ENTIRE x402 payment requirement object from the merchant as-is.
"""
dp_client = _get_dp_client() # ProcessPaymentRole をassume role してclient取得
# v2ではメタデータフィールドを削除(payment-関係のみ残す)
payload = dict(x402_payload)
if x402_version >= 2:
for key in ["description", "mimeType", "resource", "outputSchema"]:
payload.pop(key, None)
response = dp_client.process_payment(
userId=config.user_id,
paymentManagerArn=config.payment_manager_arn,
paymentSessionId=config.payment_session_id,
paymentInstrumentId=config.payment_instrument_id,
paymentType="CRYPTO_X402",
paymentInput={
"cryptoX402": {
"version": str(x402_version),
"payload": payload,
}
},
clientToken=str(uuid.uuid4()),
)
status = response.get("status", "UNKNOWN")
# 成功時、proof を内部状態に保存(次のリトライで使う)
if status == "PROOF_GENERATED":
crypto_output = response.get("paymentOutput", {}).get("cryptoX402", {})
_last_payment_context["proof"] = crypto_output
_last_payment_context["x402_version"] = x402_version
_last_payment_context["x402_payload"] = x402_payload
return response
ここで重要なのは:
paymentType="CRYPTO_X402"で x402 専用のペイメント処理を呼ぶ- AgentCore Payments が裏側で CoinbaseCDP または StripePrivy のウォレットを呼んで EIP-3009 署名を作る。エージェントのコードは秘密鍵に一切触れない
- 戻り値の status が
PROOF_GENERATEDなら成功、payment_output.cryptoX402に署名済みペイロードが入る - それを モジュールレベルのグローバル変数
_last_payment_contextに保存。次のrequest_content_with_paymentで使う
「グローバル変数で proof を持ち回る」のは Pythonics 的にやや雑ですが、エージェントのツール間で状態を共有する手段としては妥当です。ツール定義のシグネチャをシンプルに保てます。
Step 3: 支払い証明付きで再試行する(request_content_with_payment)
最後に request_content_with_payment が、保存した proof を ヘッダに載せて再リクエストします。
@tool
def request_content_with_payment(url: str) -> dict[str, Any]:
"""Retry a content request with the x402 payment proof."""
ctx = get_last_payment_context()
proof = ctx.get("proof")
x402_version = ctx.get("x402_version", 1)
x402_payload = ctx.get("x402_payload", {})
# v1とv2でヘッダ名と構造が違う
if x402_version >= 2:
header_obj = {
"x402Version": 2,
"resource": x402_payload.get("resource", ""),
"accepted": x402_payload,
"payload": proof.get("payload", proof),
"extension": x402_payload.get("resource", ""),
}
header_name = "PAYMENT-SIGNATURE"
else:
header_obj = {
"x402Version": 1,
"scheme": x402_payload.get("scheme", "exact"),
"network": x402_payload.get("network", "base-sepolia"),
"payload": proof.get("payload", proof),
}
header_name = "X-PAYMENT"
encoded_header = base64.b64encode(json.dumps(header_obj).encode()).decode()
# オンチェーン確定待ちのため exponential backoff で6回までリトライ
max_attempts = 6
with httpx.Client(timeout=30.0) as client:
for attempt in range(1, max_attempts + 1):
response = client.post(
full_url,
headers={
"Accept": "application/json",
header_name: encoded_header,
},
)
if response.status_code != 402:
break
# まだ402 — オンチェーン確定待ち
if attempt < max_attempts:
wait = 2 * attempt # 2, 4, 6, 8, 10秒
time.sleep(wait)
ここでハッとさせられるのが、「proof を付けて投げても、まだ402が返ってくることがある」 という現実です。理由はオンチェーン取引の確定(settlement)に時間がかかるためで、Facilitator が settle を実行してからブロックに取り込まれるまでのラグを吸収するために、エージェント側で exponential backoff のリトライをかけています。
「ステーブルコイン決済はミリ秒で完了する」とよく言われますが、現実にはチェーンの確定を待つ数秒〜十数秒のラグ があるんですね。Base や Solana のように高速なチェーンを選んでいるのはこのためで、Ethereum メインネットだと現実的には使い物にならないと思います。
AgentCore リソースの IAM 設計
payer-infrastructure/lib/agentcore-stack.ts の IAM ロール設計がとても勉強になります。支払い操作と支払い管理が完全に分離 された3つのロールが切られています。
// 1. ProcessPaymentRole — エージェントが assume する。ProcessPaymentのみ許可
const processPaymentRole = new iam.Role(this, 'ProcessPaymentRole', {
roleName: 'AgentCorePaymentsProcessPaymentRole',
assumedBy: new iam.AccountRootPrincipal(),
});
processPaymentRole.addToPolicy(new iam.PolicyStatement({
effect: iam.Effect.ALLOW,
actions: ['bedrock-agentcore:ProcessPayment'],
resources: ['*'],
}));
// 2. ManagementRole — アプリバックエンドが使う。Instrument/Session作成系。
// ProcessPaymentは明示的にDeny。
const managementRole = new iam.Role(this, 'PaymentsManagementRole', { ... });
managementRole.addToPolicy(new iam.PolicyStatement({
effect: iam.Effect.ALLOW,
actions: [
'bedrock-agentcore:CreatePaymentInstrument',
'bedrock-agentcore:GetPaymentInstrument',
'bedrock-agentcore:ListPaymentInstruments',
'bedrock-agentcore:CreatePaymentSession',
'bedrock-agentcore:GetPaymentSession',
'bedrock-agentcore:ListPaymentSessions',
'bedrock-agentcore:UpdatePaymentSession',
],
resources: ['*'],
}));
managementRole.addToPolicy(new iam.PolicyStatement({
effect: iam.Effect.DENY, // ← 明示的Deny
actions: ['bedrock-agentcore:ProcessPayment'],
resources: ['*'],
}));
// 3. ResourceRetrievalRole — AgentCore Payments サービス自身がassume
const resourceRetrievalRole = new iam.Role(this, 'PaymentsResourceRetrievalRole', {
assumedBy: new iam.ServicePrincipal('bedrock-agentcore.amazonaws.com'),
});
resourceRetrievalRole.addToPolicy(new iam.PolicyStatement({
effect: iam.Effect.ALLOW,
actions: [
'bedrock-agentcore:RetrieveToken',
'bedrock-agentcore:GetIdentity',
'secretsmanager:GetSecretValue',
'sts:SetContext',
],
resources: ['*'],
}));
整理すると、こんなセキュリティ設計です。
| ロール | 誰が使う | できること | できないこと |
|---|---|---|---|
| ProcessPaymentRole | エージェント | ProcessPayment(支払い実行)のみ | セッション作成、上限変更 |
| ManagementRole | アプリバックエンド | Session/Instrument の CRUD | ProcessPayment(明示Deny) |
| ResourceRetrievalRole | AgentCoreサービス | Secrets Manager から認証情報取得 | (内部用なのでユーザーは触らない) |
ポイントは 「支出する権限と支出を管理する権限が完全に分離されている」 ことです。これは ProcessPayment と CreatePaymentSession が同じロールに同居すると、もし エージェントが悪意あるプロンプト注入を受けたとき「自分でセッション作って上限を引き上げて」「自分で支払う」というシナリオが可能になってしまうため。明示Denyで完全にブロックされている設計は良いと思います。
このパターンは、AgentCore Payments を本番投入するときにそのまま真似できる良いリファレンスです。
このサンプルから学べる4つのパターン
実装を読み終えて、x402 / AgentCore Payments を自分のシステムに組み込むときに使える設計パターンを4つ整理しておきます。
1. 検証の階層化
Facilitator を呼ぶ前に、ローカルで判定可能な検証を全てやる。Facilitator は最後の砦 として使う。これでネットワーク往復のレイテンシとコストを削減できる。
2. 権限分離(最小権限)
ProcessPayment と Management は別ロールにし、Management 側で ProcessPayment を 明示Deny する。プロンプトインジェクション耐性を確保する。
3. ペイロードは「まるごと渡す」
エージェントのツール設計で、x402_payload の中身を LLM に解釈させない。accepts[0] をそのままバイト列として次のツールに渡す。LLM がフィールドを変に解釈して壊すリスクを減らす。
4. オンチェーン確定待ちの exponential backoff
「ステーブルコイン決済は即時」は嘘で、実際には数秒〜十数秒のラグがある。402 が返ってきたら最大6回までリトライ で吸収する。
Facilitator のことをもう少し
サンプルがハードコードしている https://www.x402.org/facilitator は、x402.org が提供しているパブリック Facilitator です。Coinbase の CDP Facilitator は別エンドポイントで、月1000トランザクションまで無料、超過分は $0.001/件という料金体系(2026年5月時点 Coinbase x402 Documentation)。
The CDP facilitator covers: Base, Polygon, Arbitrum, World, and Solana with support for All ERC-20 Tokens via Permit2 or EIP-3009 tokens like USDC.
つまり、Facilitator は実装上は入れ替え可能で、自分で立てることもできます。x402 のオープン仕様の核心はここで、プロトコル自体は誰も独占していない。AWSが東京リージョンに来なくても、AgentCoreじゃないクラウドでも、自前のFacilitatorと自前の402を返すLambda(あるいはNginx拡張、Cloudflare Workers)で同じことができる、というのが大事なポイントです。
自分の所感
サンプルを読み終わって正直「設計が綺麗すぎる」と感じました。x402 という新しいプロトコルを、AWS の既存サービス(CloudFront / Lambda@Edge / S3 / IAM)にどう接続するかが、過剰な抽象化なしに素直に書かれていて、リファレンス実装の鏡みたいなコードです。
特に Lambda@Edge を使った設計が良かったです。**「課金レイヤーをCDNに乗せる」**という発想は、これまでの API ベースのSaaSとは違う発想で、より低レイヤーに近い。これは「コンテンツ配信」と「決済」を融合させる、新しいインフラパターンの萌芽だと思います。
AWS 側の Lambda@Edge の制約(環境変数なし、サイズ制限、デプロイ時間)はあるものの、グローバルにエージェントから叩かれる「課金API」を提供するときには、エッジが圧倒的に有利。CloudFront の地理的分散をそのまま活かせる。
逆に「これでいいんだっけ?」と感じた部分もあります。
- Facilitator の選定がプロダクション運用で重要になる。x402.org のパブリック Facilitator は便利だけど、SLA や障害対応が不明。本番では Coinbase CDP の Facilitator か、自前で立てる選択肢を検討する必要がある
- オンチェーン確定待ちのリトライ間隔(2秒×attempt)が雑。Base Sepolia なら良いけど、Polygon やオンメインネットだと違うチューニングが必要かもしれない
- エージェントの会話履歴に proof のような暗号情報が流れる。ロギング設計を間違うと、署名済みペイロードがログに残ってしまう可能性がある(同じ署名は nonce が同じなら使い回せないので致命的ではないが、観測性的には注意)
それでも、このサンプルは「x402 とは何か」を理解するための最高の教材だと思います。仕様書を読むより、このコードを読んだ方が10倍速く分かる。
次回は、可能なら 自前で x402 対応のAPIサーバーを立ててみる あたりに踏み込んでみたいです。Lambda@Edge である必要は別になくて、Bun / Hono みたいな軽量サーバーでもいけるはずなので、もっとシンプルな実装を試してみたい。あと、JPYCのような円建てステーブルコインで x402 が動くか、というのも気になっています。これは別記事になりますね。
x402 は2026年に大きく動くプロトコルだと思うので、引き続き追いかけたいと思っています。