TL;DR
- cloudsecop.net
/api/summarizeinvoke Bedrock Claude Opus 4.7 từ Cloudflare Worker mà không có long-lived AWS access key trongwrangler secret. Federation qua OIDC: Worker là IdP, AWS là relying party.- Worker mint JWT RS256 (~5ms) → STS
AssumeRoleWithWebIdentity(~200ms) → temp credential expire 1h, cache trong KV → Bedrock InvokeModel qua AI Gateway (~2-3s cold, ~50ms khi AI Gateway cache HIT).- JWKS public tại
https://khavan.khavan.workers.dev/.well-known/jwks.json— STS fetch để verify signature. AWS IAM trust policy conditionStringEqualstrêniss+aud+sub.- Implementation thực tế trong repo:
worker/lib/jwt.ts(RS256 PKCS#8 import qua Web Crypto),worker/lib/aws-sts.ts(STS POST + KV cache, refresh margin 5 phút),worker/lib/bedrock.ts(aws4fetch SigV4 sign + AI Gateway URL).- Đừng lưu AWS access key trong
wrangler.jsonchoặc Secrets Store — Worker compromise → key leak vô thời hạn. Federation key ngắn 1h, scope chỉ Bedrock InvokeModel, có thể revoke instant qua IAM role.- Trust policy chặt:
aud = sts.amazonaws.com,iss = khavan.khavan.workers.dev,sub = bedrock-summarize. Một subject một role — không reuse JWT cho service khác.
Vấn đề — tại sao không dùng AWS access key
Tháng 7 năm trước tôi build feature POST /api/summarize cho cloudsecop.net: user paste URL blog post, Worker fetch HTML, gọi Claude Opus tóm tắt 200 từ, lưu vào KV cache 7 ngày. Đơn giản về business logic. Phức tạp ở auth: Worker phải gọi Bedrock từ AWS us-east-1, mà Bedrock require IAM SigV4 signature.
Lựa chọn dễ nhất là tạo IAM user, generate access key, wrangler secret put AWS_ACCESS_KEY_ID. Nhanh, hoạt động ngay. Nhưng tôi từ chối — vì 3 lý do:
- Access key sống vĩnh viễn. Worker source code có thể leak qua npm dep hack, qua dev push nhầm
.env, qua hijackwranglertoken. Một khi leak, attacker có quyền invoke Bedrock vô thời hạn cho tới khi tôi phát hiện vàaws iam delete-access-key. Token discovery thường mất hàng tháng. - Scope khó hẹp. IAM policy có thể
Action: bedrock:InvokeModel, nhưng đó là cho cả region. Không có cách nói “chỉ Worker này, không phải laptop của tôi”. - Audit kém. CloudTrail log AccessKeyId, nhưng AccessKeyId = key nào dùng, không nói được “request đến từ Worker invocation X tại edge Y”. Mất context.
OIDC federation giải cả ba. Worker mint JWT có lifetime 15 phút, claim sub = bedrock-summarize. STS verify qua JWKS, cấp temp credential AccessKeyId + SecretAccessKey + SessionToken sống 1h. Credential này chỉ valid cho RoleArn cụ thể đã ràng buộc trong trust policy với iss + aud + sub của Worker tôi. Nếu Worker source leak, attacker không có private key (private key ở Cloudflare Secrets Store) → không mint được JWT → STS từ chối.
Kiến trúc end-to-end
┌──────────────────────────────────────────────────────────────┐
│ Cloudflare Worker (khavan) │
│ │
│ POST /api/summarize │
│ │ │
│ ▼ │
│ ┌─────────────────────┐ │
│ │ 1. bedrock.ts │ │
│ │ bedrockInvoke() │ │
│ └────────┬────────────┘ │
│ │ │
│ ▼ getBedrockCreds() │
│ ┌─────────────────────┐ cache HIT (45min remaining) │
│ │ 2. aws-sts.ts │────────────────────────┐ │
│ │ getBedrockCreds() │ │ │
│ └────────┬────────────┘ │ │
│ │ cache MISS │ │
│ ▼ │ │
│ ┌─────────────────────┐ │ │
│ │ 3. jwt.ts │ │ │
│ │ signJwt() RS256 │ ~5ms │ │
│ └────────┬────────────┘ │ │
│ │ JWT compact │ │
│ │ │ │
│ ┌────────▼────────────┐ │ │
│ │ 4. POST sts.amazon │ │ │
│ │ AssumeRoleWithWeb│ ~200ms │ │
│ │ Identity │ │ │
│ └────────┬────────────┘ │ │
│ │ temp creds (1h) │ │
│ ▼ ▼ │
│ ┌─────────────────────────────────────────────────┐ │
│ │ 5. KV cache (OIDC_CREDS_CACHE) │ │
│ │ TTL = expiresAt - now - 5min │ │
│ └────────┬────────────────────────────────────────┘ │
│ ▼ │
│ ┌─────────────────────┐ │
│ │ 6. aws4fetch SigV4 │ │
│ │ sign request │ │
│ └────────┬────────────┘ │
│ │ │
└───────────┼──────────────────────────────────────────────────┘
▼
┌─────────────────────────────┐ ┌──────────────────────┐
│ Cloudflare AI Gateway │ │ AWS │
│ - cache, log, rate limit │──────►│ STS verify JWKS │
│ - forward to Bedrock │ │ Bedrock invoke model │
└─────────────────────────────┘ └──────────────────────┘
Sáu component ở Worker side, tất cả trong repo này dưới worker/lib/. Tôi sẽ đi qua từng cái với code thật, không phải pseudocode.
Step 1 — Mint JWT RS256 với Web Crypto
worker/lib/jwt.ts là implementation gọn nhất có thể. Web Crypto của Workers runtime đủ để sign RS256 — không cần node-jsonwebtoken.
// worker/lib/jwt.ts
export async function signJwt(
claims: JwtClaims,
privateKeyPem: string,
kid: string,
): Promise<string> {
const header = { alg: "RS256", typ: "JWT", kid };
const headerB64 = b64url(JSON.stringify(header));
const claimsB64 = b64url(JSON.stringify(claims));
const message = `${headerB64}.${claimsB64}`;
const key = await importPrivateKey(privateKeyPem);
const sigBuf = await crypto.subtle.sign(
"RSASSA-PKCS1-v1_5",
key,
TEXT_ENCODER.encode(message),
);
return `${message}.${b64url(sigBuf)}`;
}
async function importPrivateKey(pem: string): Promise<CryptoKey> {
const pemContents = pem
.replace(/-----BEGIN PRIVATE KEY-----/, "")
.replace(/-----END PRIVATE KEY-----/, "")
.replace(/\s/g, "");
const binary = Uint8Array.from(atob(pemContents), (c) => c.charCodeAt(0));
return crypto.subtle.importKey(
"pkcs8",
binary,
{ name: "RSASSA-PKCS1-v1_5", hash: "SHA-256" },
false,
["sign"],
);
}
Hai chi tiết dễ sai:
- Format key phải PKCS#8, không PKCS#1. Đây là format mặc định khi
openssl genrsaở phiên bản mới +openssl pkcs8 -topk8. Header-----BEGIN PRIVATE KEY-----(khôngRSA PRIVATE KEY). - Header phải có
kid. STS dùng kid để pick public key từ JWKS. Nếu JWKS có nhiều key (rotation), kid là cách matching.
Sinh keypair một lần:
# Private key PKCS#8
openssl genpkey -algorithm RSA -out private-pkcs8.pem -pkeyopt rsa_keygen_bits:2048
# Public key
openssl rsa -pubout -in private-pkcs8.pem -out public.pem
# Tính kid — SHA-256 thumbprint của public key
openssl rsa -in private-pkcs8.pem -pubout -outform DER 2>/dev/null \
| openssl dgst -sha256 -binary | base64 | tr '+/' '-_' | tr -d '='
Lưu private key vào Cloudflare Secrets Store (xem bài aws-secrets-manager-vs-cloudflare-secrets):
wrangler secrets-store secret create \
--store-id <uuid> \
--name oidc-rsa-private-key \
--value "$(cat private-pkcs8.pem)"
Đo độ trễ: signJwt trung bình 4-6ms trong Worker isolate, dominantly là crypto.subtle.sign. PEM import được cache implicit bởi Web Crypto trong cùng isolate, nên call thứ hai trở đi gần như instant.
Step 2 — JWKS endpoint cho AWS verify
AWS STS phải fetch public key từ /.well-known/jwks.json trên Worker domain. Endpoint này phải public, không gate Access.
// worker/routes/jwks.ts
export async function handleJwks(env: Env): Promise<Response> {
const pubKey = await env.OIDC_PUBLIC_JWK.get(); // pre-computed JWK JSON string
if (!pubKey) {
return new Response("not configured", { status: 500 });
}
return new Response(`{"keys":[${pubKey}]}`, {
headers: {
"Content-Type": "application/json",
"Cache-Control": "public, max-age=3600",
},
});
}
JWK JSON sinh từ public PEM:
# Dùng node-jose hoặc python authlib để convert PEM → JWK
python3 -c "
from authlib.jose import RSAKey
import json
with open('public.pem') as f:
pem = f.read()
key = RSAKey.import_key(pem)
jwk = key.as_dict()
jwk['kid'] = 'kid-from-step-1'
jwk['use'] = 'sig'
jwk['alg'] = 'RS256'
print(json.dumps(jwk))
"
Output trông như:
{
"kty": "RSA",
"n": "rXAUL2Z...",
"e": "AQAB",
"kid": "abc123def456...",
"use": "sig",
"alg": "RS256"
}
Cache-Control: max-age=3600 quan trọng — STS cache JWKS trong khoảng 1h, nên khi rotate key bạn phải plan: deploy JWKS với cả old + new key trước, đợi cache cũ expire ở STS, sau đó deploy code dùng new kid.
Step 3 — STS AssumeRoleWithWebIdentity
worker/lib/aws-sts.ts là trái tim của flow. Đoạn quan trọng:
// worker/lib/aws-sts.ts
const now = Math.floor(Date.now() / 1000);
const jwt = await signJwt(
{
iss: env.OIDC_ISSUER, // "https://khavan.khavan.workers.dev"
sub: SUB, // "bedrock-summarize"
aud: AUD, // "sts.amazonaws.com"
iat: now,
nbf: now,
exp: now + 15 * 60,
},
env.OIDC_PRIVATE_KEY,
env.OIDC_KID,
);
const params = new URLSearchParams({
Action: "AssumeRoleWithWebIdentity",
Version: "2011-06-15",
RoleArn: env.AWS_BEDROCK_ROLE_ARN,
RoleSessionName: `khavan-summarize-${now}`,
WebIdentityToken: jwt,
DurationSeconds: "3600",
});
const stsResponse = await fetch(STS_ENDPOINT, {
method: "POST",
headers: {
"Content-Type": "application/x-www-form-urlencoded",
Accept: "application/json",
},
body: params.toString(),
});
Một số decision đáng nói:
- JWT exp 15 phút: đủ cho STS exchange + buffer. Không cần dài hơn vì STS đã trả temp credential 1h.
- DurationSeconds 3600: max cho
AssumeRoleWithWebIdentitymặc định là 1h. Có thể tăng lên 12h nếu role cóMaxSessionDurationcao, nhưng tôi giữ 1h vì blast radius nhỏ. - RoleSessionName:
khavan-summarize-<timestamp>. Tag này hiện trong CloudTrail làm session attribution — incident response query được “session nào assume role tại 02:15:32”. Accept: application/json: STS lịch sử trả XML. Header này request JSON. Note: tôi vẫn có fallback parse XML trong code phòng STS regression.
STS response (success) trông như:
{
"AssumeRoleWithWebIdentityResponse": {
"AssumeRoleWithWebIdentityResult": {
"Credentials": {
"AccessKeyId": "ASIA...",
"SecretAccessKey": "wJa...",
"SessionToken": "FwoG...",
"Expiration": 1737583200
},
"SubjectFromWebIdentityToken": "bedrock-summarize",
"AssumedRoleUser": {
"Arn": "arn:aws:sts::123:assumed-role/BedrockOIDCRole/khavan-summarize-1737579600"
}
}
}
}
Đo độ trễ: STS exchange trung bình 180-220ms từ Cloudflare edge tới sts.amazonaws.com (us-east-1). Dominantly là RTT + STS internal JWKS fetch.
Step 4 — IAM trust policy phía AWS
Đây là chỗ chặn attacker giả mạo Worker. Trust policy ràng buộc role chỉ assume được nếu JWT match đủ 3 condition:
resource "aws_iam_openid_connect_provider" "khavan" {
url = "https://khavan.khavan.workers.dev"
client_id_list = ["sts.amazonaws.com"]
thumbprint_list = [
# SHA-1 thumbprint của Cloudflare cert chain — lấy bằng:
# echo | openssl s_client -connect khavan.khavan.workers.dev:443 \
# -servername khavan.khavan.workers.dev 2>/dev/null \
# | openssl x509 -fingerprint -sha1 -noout
"9e99a48a9960b14926bb7f3b02e22da2b0ab7280"
]
}
resource "aws_iam_role" "bedrock_oidc" {
name = "BedrockOIDCRole"
max_session_duration = 3600 # 1h max
assume_role_policy = jsonencode({
Version = "2012-10-17"
Statement = [{
Effect = "Allow"
Principal = {
Federated = aws_iam_openid_connect_provider.khavan.arn
}
Action = "sts:AssumeRoleWithWebIdentity"
Condition = {
StringEquals = {
"khavan.khavan.workers.dev:aud" = "sts.amazonaws.com"
"khavan.khavan.workers.dev:sub" = "bedrock-summarize"
}
}
}]
})
}
resource "aws_iam_role_policy" "bedrock_invoke" {
name = "BedrockInvokeOnly"
role = aws_iam_role.bedrock_oidc.id
policy = jsonencode({
Version = "2012-10-17"
Statement = [{
Effect = "Allow"
Action = ["bedrock:InvokeModel"]
Resource = [
# Cross-region inference profile (us.anthropic.claude-opus-4-7)
"arn:aws:bedrock:us-east-1:123456789012:inference-profile/us.anthropic.claude-opus-4-7-v1:0",
"arn:aws:bedrock:us-east-1::foundation-model/anthropic.claude-opus-4-7-v1:0",
"arn:aws:bedrock:us-east-2::foundation-model/anthropic.claude-opus-4-7-v1:0",
"arn:aws:bedrock:us-west-2::foundation-model/anthropic.claude-opus-4-7-v1:0"
]
}]
})
}
Ba điểm quan trọng:
aud+subcondition là StringEquals, không StringLike. Một wildcard chỗ này = compromised: bất kỳ JWT nào tôi mint chosubkhác cũng assume được role.max_session_duration = 3600: hard cap. Nếu attacker exfil temp credential, 1h sau credential dead.- Resource list liệt kê foundation model ARN cho cả 3 region us-east-1, us-east-2, us-west-2 — vì
us.anthropic.claude-opus-4-7là cross-region inference profile, AWS route request tới region khả dụng nhất.
Step 5 — KV cache temp credential
Mỗi STS call ~200ms. Nếu Worker invoke Bedrock 10 lần/phút, đó là 2 giây latency overhead chỉ để re-assume role không cần thiết. Cache trong KV.
// worker/lib/aws-sts.ts (snippet)
const CACHE_KEY = "bedrock:temp-creds";
const REFRESH_MARGIN_SEC = 5 * 60; // refresh 5 phút trước expiry
// Đọc cache
if (env.OIDC_CREDS_CACHE) {
const cached = await env.OIDC_CREDS_CACHE.get(CACHE_KEY, "json");
if (cached && typeof cached === "object") {
const creds = cached as TempCredentials;
const now = Math.floor(Date.now() / 1000);
if (creds.expiresAt - REFRESH_MARGIN_SEC > now) {
return creds; // ~5ms KV read
}
}
}
// ... STS exchange ...
// Ghi cache, TTL = expiresAt - now - margin
if (env.OIDC_CREDS_CACHE) {
const ttl = Math.max(60, creds.expiresAt - now - REFRESH_MARGIN_SEC);
await env.OIDC_CREDS_CACHE.put(CACHE_KEY, JSON.stringify(creds), {
expirationTtl: ttl,
});
}
REFRESH_MARGIN_SEC = 5 * 60 quan trọng: nếu credential expire trong 5 phút, trả null (force re-mint). Tránh edge case race: cache trả về credential expire trong 30 giây, request tới Bedrock thì expire giữa chừng → 403.
KV cache cross-region eventually consistent. Trong vòng vài giây sau put, tất cả edge thấy value mới. Cho temp credential lifetime 1h, eventual consistency là OK.
Đo: cache HIT path = ~5-15ms (chỉ KV read). Cache MISS path = ~200-250ms (JWT sign + STS exchange + KV write). Hit rate thực tế ở cloudsecop.net ~95% vì traffic cluster theo burst.
Step 6 — SigV4 sign + AI Gateway invoke
worker/lib/bedrock.ts dùng aws4fetch lib để SigV4 sign request:
// worker/lib/bedrock.ts (snippet)
import { AwsClient } from "aws4fetch";
const aws = new AwsClient({
accessKeyId: creds.accessKeyId,
secretAccessKey: creds.secretAccessKey,
sessionToken: creds.sessionToken,
region: REGION,
service: "bedrock",
});
// SigV4 sign against REAL Bedrock endpoint (host header matters)
const signed = await aws.sign(
`https://bedrock-runtime.${REGION}.amazonaws.com/model/${encodeURIComponent(MODEL_ID)}/invoke`,
{
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(body),
},
);
// Then send to AI Gateway URL with the signed headers
const gatewayUrl =
`https://gateway.ai.cloudflare.com/v1/${env.CF_AI_GATEWAY_ACCOUNT}` +
`/${env.CF_AI_GATEWAY_NAME}/aws-bedrock/bedrock-runtime/${REGION}` +
`/model/${encodeURIComponent(MODEL_ID)}/invoke`;
const response = await fetch(gatewayUrl, {
method: "POST",
headers: signed.headers, // SigV4 signed against Bedrock host
body: JSON.stringify(body),
});
Trick quan trọng: sign request đối với host bedrock-runtime.us-east-1.amazonaws.com rồi gửi tới host gateway.ai.cloudflare.com. AI Gateway forward request lên Bedrock và Bedrock verify SigV4 với host header của Bedrock — phải match host đã sign. AI Gateway transparent về phía AWS.
Lợi ích đi qua AI Gateway:
- Cache identical request (hash của body) → cache HIT trả về trong ~50ms thay vì 2-3s.
- Log mọi invoke vào Cloudflare Logs — token in/out, latency, model.
- Rate limit per-API-key.
- Fallback chain: nếu Bedrock down, switch sang Workers AI Llama. (Tôi chưa enable feature này.)
Đo end-to-end:
| Step | Cold | Warm (cred cache HIT) | AI Gateway cache HIT |
|---|---|---|---|
| Mint JWT | 5ms | (skip) | (skip) |
| STS exchange | 200ms | (skip) | (skip) |
| KV write | 5ms | (skip) | (skip) |
| SigV4 sign | 2ms | 2ms | 2ms |
| Bedrock invoke | 2500ms | 2500ms | 50ms |
| Total | ~2700ms | ~2500ms | ~50ms |
Với traffic thực ở cloudsecop.net: tỷ lệ AI Gateway cache HIT 30-40% (nhiều user paste cùng URL), warm path 95% (cred cache rộng). Median latency thực tế: 1.8s.
Cạm bẫy đã đụng
1. Cloudflare cert thumbprint thay đổi. OIDC provider trust thumbprint của TLS cert. Cloudflare rotate cert thì thumbprint đổi → STS fail “InvalidIdentityToken”. Workaround: pre-fetch thumbprint của Let’s Encrypt R3/R10 ISRG Root cũng add vào list. Hoặc switch sang getOpenIDConnectProvider của AWS với root CA list (mới hỗ trợ 2024).
2. PKCS#1 vs PKCS#8. openssl genrsa (cũ) sinh PKCS#1 (-----BEGIN RSA PRIVATE KEY-----). Web Crypto chỉ accept PKCS#8 (-----BEGIN PRIVATE KEY-----). Convert: openssl pkcs8 -topk8 -nocrypt -in pkcs1.pem -out pkcs8.pem.
3. JWKS endpoint behind Access. Quên rằng STS phải fetch JWKS public — nếu /.well-known/jwks.json gate qua Cloudflare Access, STS fail. Phải explicit bypass Access cho path này.
4. KV cache với lifetime 0. Edge case: STS trả credential expire trong 60 giây vì clock skew. TTL = 0 - 300 = -300. Code Math.max(60, ...) đã handle, nhưng đầu version đầu tôi forget → KV reject negative TTL → cache không lưu → mỗi request là cold path.
5. AI Gateway URL thiếu encode. MODEL_ID = "us.anthropic.claude-opus-4-7" — dots phải encodeURIComponent. Nếu không, AI Gateway route sai → 404. Tôi mất 30 phút debug.
6. Bedrock model deprecation. anthropic.claude-opus-4-6-v1:0 retire sau 6 tháng. Schedule alert + bump model qua wrangler env var, không hardcode.
Threat model — cái gì không bảo vệ
OIDC federation không bảo vệ khỏi:
- Cloudflare account compromise: nếu attacker có quyền edit Worker code, họ deploy code mới mint JWT với
sub = bedrock-summarize→ bypass trust policy. Mitigation: 2FA cho Cloudflare account, deploy chỉ qua GitHub Actions OIDC (không local), audit Worker version history. - JWKS poisoning: nếu attacker compromise Worker và replace JWKS endpoint với public key của họ, STS sẽ verify JWT mới ký bằng private key của họ. Mitigation: JWKS rotation rate-limited, Workers Audit Log alert khi
/.well-known/jwks.jsonthay đổi. - Credential exfiltration trong logs: temp credential leak qua
console.log(tôi đã từng debug print accidentally). Mitigation: redact patternASIA[A-Z0-9]{16}trong log pipeline.
OIDC federation bảo vệ khỏi:
- Worker source code leak (vì private key ở Secrets Store, không ở code).
- Long-tail key reuse (max 1h temp credential).
- Cross-tenant abuse (sub claim ràng buộc, attacker không mint được JWT cho khác sub).
Bottom line
OIDC federation từ Cloudflare Worker tới AWS không phải pattern thuần lý thuyết — implementation trong worker/lib/{jwt,aws-sts,bedrock}.ts của repo này chạy production cho /api/summarize của cloudsecop.net và serve ~1000 invoke/ngày. Cold path 2.7s, warm path 1.8s, cache HIT 50ms. Đổi lại tôi không bao giờ phải rotate AWS access key, không phải lo Worker code leak, scope auth chặt xuống một sub + một role + một API. Setup phức tạp hơn wrangler secret put AWS_ACCESS_KEY_ID nhưng dài hạn an toàn hơn nhiều bậc — đáng cho mọi Worker gọi AWS API ≥ 100 lần/ngày.
Checklist khi deploy OIDC federation cho service mới:
- Sinh keypair PKCS#8 (không PKCS#1), key size ≥ 2048
- Private key lưu trong CF Secrets Store với binding, không hardcode trong wrangler.jsonc
- JWKS endpoint public
/.well-known/jwks.json, bypass Access nếu có - Cache-Control max-age=3600 trên JWKS response
- AWS OIDC provider thumbprint match Cloudflare TLS cert chain
- IAM role trust policy
StringEquals(không StringLike) trênaud+sub+iss - IAM role
max_session_duration ≤ 3600cho high-privilege role - IAM role policy resource cụ thể (không
*), action cụ thể (chỉbedrock:InvokeModel) - KV cache
REFRESH_MARGIN ≥ 5 phút,Math.max(60, ttl)guard - CloudTrail alarm trên
AssumeRoleWithWebIdentitytừ unexpectedRoleSessionNamepattern - Log redact temp credential pattern
ASIA[A-Z0-9]{16} - DR plan: rotate JWKS key (dual-publish window 24h trước khi cut over kid)
- Deploy chỉ qua CI với GitHub OIDC, không deploy local