把 Cognito 接進 Gateway 的那一輪,我原本以為最麻煩的部分已經處理完了。Human SSO 可以登入,Gateway 也能驗 JWT signature,MCP policy 則已經寫好需要哪些使用者資訊。照這個進度看,剩下的工作應該只是把 claim 名稱填進設定檔。
真正拿 ID token 和 access token 對照後,情況沒有那麼簡單。授權規則需要的使用者 context 出現在 ID token 裡,送往 API 的 access token 卻沒有同樣的資料。登入沒有失敗,Token 也確實由 Cognito 簽發,但 Gateway 手上的那枚 access token 仍不足以完成原本設計的 policy。
當時最直覺的解法,是把資料比較完整的 ID token 送給後端。我沒有採用這條捷徑,因為這個 resource 的 contract 要求 OAuth access token。為了多拿一個 claim 改送 ID token,會把「使用者完成登入」和「client 取得 resource access」混在一起,後續連 audience 和 audit 都很難解釋。
我把這個落差縮成可離線重現的 Lab 02。公開案例用 team=platform 取代實際欄位,其中四筆最能說明問題:
valid_access ALLOW ALLOW
wrong_audience DENY AUDIENCE_MISMATCH
access_missing_team DENY CLAIM_MISSING
id_token_has_team DENY TOKEN_TYPE_INVALID
七枚 Token 都由同一個本機 issuer 產生,也使用同一把 2048-bit RSA private key 簽署。它們的 signature 都能通過,最後卻只有 valid_access 被放行。少了 team 的 access token 停在 policy,帶著 team=platform 的 ID token 則更早就因 Token type 不符而被拒絕。

這張圖保留的是 2026-08-19 完成 Day 8 slice 時的實際輸出,當時有 24 個測試。Lab 後來繼續承接 Day 9 到 Day 12,目前從 repo root 執行 make lab-02-check 會跑完整的 72 個測試。原始 JSON summary、hash 和圖片製作方式都放在 Day 8 evidence,指令和結果不需要從圖片上抄。
JWT 是承載 header、claims 和 signature 的格式。把 payload decode 出來很適合除錯,卻不代表裡面的 sub、team 或 scope 已經可信。Verifier 還得用自己設定的 issuer、key、audience 和演算法完成驗證,不能從待驗 Token 抄一份 expected value 回去。
OIDC 和 OAuth 又各自多回答了一層問題。OpenID Connect 的 ID Token 描述 End-User authentication,OAuth access token 則交給 resource 判斷這次存取是否符合授權條件。OpenID Connect Core 1.0 對 ID Token 的定位很清楚,兩種 Token 即使都長成三段式 JWT,resource 也不能因此共用同一套驗證規則。
這次遇到的問題正好跨過三層。JWT signature 是真的,Cognito 登入也成功,Gateway 還是得依 access-token contract 檢查 resource、client、scope 和 policy input。前面的成功結果無法替後面的授權判斷代簽。
Lab 02 把 Token 從「看得懂」走到「可以交給 policy」拆成四道 gate。每一道都留下穩定的拒絕 stage 和 decision code,這樣 on-call 才能分辨問題出在 key、registered claim、OAuth context,還是應用 policy。

第一道檢查 header 和 key。Server 固定允許 RS256,kid 只能到預先信任的 JWKS 裡找 key,也不會跟著 Token 內的 jku 去抓任意 URL。Lab 使用 at+jwt 和 id+jwt 做 explicit typing,讓 access token 和 ID token 從入口就走不同規則。
第二道驗 signature、iss、aud、exp 等 registered claims。iss 要與預先設定的發行者完全相符,aud 要包含目前 resource,時間 claim 也必須落在允許的有效區間。wrong_issuer 特別使用同一把 private key 簽署,用來證明 cryptographic verification 成功不會順便替 issuer 的語意背書。
第三道進入 OAuth context,檢查 token_use、client_id 和 required scope。第四道才處理 team、role 或 resource context 等應用 policy input。到這裡即使每個 claim 都存在,policy 仍可能依 action、resource 和環境條件回覆 DENY。
RFC 8725 建議 verifier 固定允許的演算法,也討論 cross-JWT confusion、explicit typing 和外來 jku/x5u 的風險。同一個平台開始同時處理 ID token、access token 和多個 issuer 後,這些規則很快就會從安全硬化變成日常整合需求。
Lab 正向 Token 的 audience 是:
mcp://lab/observability/query
wrong_audience 只把它換成另一個 resource:
mcp://lab/admin/delete
兩枚 Token 的 signature、subject、client 和 scope 都相同,差別只有 aud。如果 observability resource 關掉 audience validation,原本簽給 admin resource 的 Token 也可能被接受。當平台上有多個 MCP Server,或 Agent 開始互相呼叫時,這類 Token replay 很難只靠 URL path 看出來。
RFC 7519 要求不在 aud 裡的 processor 拒絕 Token。RFC 8725 也把 audience validation 列為避免 Token 被換到另一個 context 的手段。對這次的架構來說,aud 是 resource boundary,不只是 JWT payload 裡一個有值就好的欄位。
Audience 能證明目前 resource 是預期接收者之一,卻不會直接授權每一個 Tool。query_logs 和 delete_index 是否允許執行,仍需要 Tool-level policy 依 principal、action、resource 和其他 context 判斷。
scope=observability.query 是 authorization server 授予的 permission string,適合表達 API 或能力的粗粒度邊界。team=platform 則是這個 Lab 用來代表應用 policy context 的合成 claim,應用可以拿它做 ABAC,也可以完全不採用。
兩者的來源和生命週期不同。即使 team 存在,平台仍要知道誰能發這個欄位、允許哪些值,以及人員轉組或離職後多久更新。反過來說,只有 scope 也不一定能回答某個高風險 Tool 在目前環境能否執行。
Lab 因此保留兩種錯誤,而不是全部壓成 invalid_token:
missing_scope → SCOPE_MISSING (OAuth context)
access_missing_team → CLAIM_MISSING (Application policy)
對外 response 可以維持模糊,不必透露內部政策。內部 audit event 至少要留下拒絕 stage 和 stable code,否則 on-call 很難判斷該找 IdP claim mapping、OAuth client、JWKS rotation,還是應用 policy 的 owner。
我另外整理了一份 Token Claim Boundary,逐欄記錄每個 claim 能證明什麼,又不能拿來代替什麼。像 sub 可以識別 issuer namespace 裡的 subject,卻不能直接複製成 Day 7 的 Human、Service、Agent 和 Workload 四種責任。
Lab 的 id_token_has_team 帶著以下資料:
{
"sub": "user/sre-oncaller",
"aud": "sre-console",
"token_use": "id",
"team": "platform"
}
這枚 Token 在第一道 gate 就得到 TOKEN_TYPE_INVALID。team 並沒有因為放在 ID token 裡就變成假資料,問題是目前 resource 明確要求 access-token profile,所以 verifier 根本不該讓它走進同一套 policy。
這不是所有 API 都必須遵守的全球禁令。有些產品會明確接受 ID token 作為自己的 authentication credential,Amazon Cognito 文件也描述過這類用法。若產品真的選擇這種 contract,就應該建立獨立 verifier,驗 ID token 的 issuer、client audience、signature、expiry 和 token_use=id,而不是讓 endpoint 在兩種 Token 之間任選一枚。
Agent Gateway 和 MCP resource 在這個設計裡採用 OAuth access-token contract。Policy 若需要 team,我會回頭調整 access-token claims、scope 或外部 policy data source,不靠另一枚 Token 把資料偷渡進來。
公開 Lab 使用 resource-bound aud,但它不是 Cognito emulator。Lab 裡的 at+jwt/id+jwt 是為了示範 mutually exclusive validation rules,Cognito 本身主要使用 token_use 區分 Token 類型。
AWS 現行文件還有幾個容易在 generic JWT middleware 裡配錯的差異:
aud 是 app client ID,access token 則用 client_id 表示 app client。aud 只有在 authorization request 使用 resource binding 時才會出現,值是預定授權的 API URL。kid,兩種 Token 必須獨立驗證。token_use=access、issuer、expiry、client 和 scope。這些行為可在 Cognito access token 和 Cognito JWT verification 交叉確認。Generic library 若一律要求 access token 具備 aud,又沒有先確認 Cognito flow 是否用了 resource binding,很容易在「關掉 audience validation」和「所有 request 都失敗」之間做錯選擇。
如果 Gateway policy 需要額外 claim,也要先確認那個 claim 是否真的會出現在 access token。Cognito 的 Pre Token Generation trigger 可以客製 access-token claims,但 Human 和 M2M 適用的 event version、feature plan 及觸發條件並不完全相同。Pre Token Generation trigger 應該和實際 app client flow 一起核對,不能只看 Lambda 設定畫面已經啟用就當作完成。
我最後把每條 route 的 Token validation profile 寫成 contract,明確列出接受哪種 Token、信任哪個 issuer、對應哪個 client 和 resource,以及需要哪些 scope 與 claim。IdP 沒有發出 contract 需要的內容時,就修改發行方式或 policy 設計,不在 verifier 裡悄悄少驗一項。
從 repo root 執行以下三個 target:
make lab-02-up
make lab-02-check
make lab-02-demo
lab-02-check 目前會跑完整共用 Lab 的 72 個測試,lab-02-demo 只執行 Day 8 的七組 JWT case。最後應該看到:
7/7 cases matched
Encoded JWT persisted: no
Private key persisted: no
需要讓 CI 或其他工具讀取時,可以改成 JSON output:
uv run --directory labs/02-identity-boundary \
identity-boundary run --output json
每次 run 都會建立 manifest.json、summary.json、events.jsonl、public jwks.json、預期結果和合成 claims。Compact JWT 與 private key 只留在程序記憶體,測試也會掃描 artifact,確認兩者沒有落盤。
清理使用明確的 Lab root 和 ownership marker:
make lab-02-down
Cleanup 只會處理位於 Lab root、不是 symlink,而且帶正確 marker 的 artifacts/。它不依賴寬鬆 glob,也不會碰同層的其他目錄。
這個離線 Lab 固定了 validation boundary,沒有把 JWKS 和 Token lifecycle 的 production 問題一起藏進 demo。真的接 IdP 時,下面四項仍需要平台 owner 做決策:
kid 何時 refresh,issuer 暫時不可用時接受哪一版既有 cache。exp/nbf 可以容忍多少 leeway,應依風險和基礎設施時間同步狀況決定。目前的 72 個測試會保護共用 Lab,Day 8 的七組 case 則固定本文討論的 JWT boundary。它們不會替 production 完成 key rotation、即時離職停權或完整 ABAC,這幾筆責任仍要回到實際平台設計裡處理。
經過這一輪調整,Gateway 已經可以確認 Token 由哪個 issuer 發出、交給哪個 resource、由哪個 client 取得,也能分開檢查 scope 和 policy claim。這比單純驗 signature 多了完整的 resource contract,但 audit 目前仍只看得到 Token 內的 subject。
假設 user/sre-oncaller 要求 sre-investigator@v1 執行查詢,真正送出 request 的又是 Kubernetes 裡另一個 runtime,單靠 access token 無法把三者的責任串回來。把 Agent 或 Pod 名稱繼續塞進 claims,只會讓 Token 越來越胖,仍然沒有一份可查詢的委派關係。
Day 9 會沿用同一個 Lab,替 Human、Agent、Workload 和 credential context 定義 Delegation Context。下一個要解的問題,是如何在不偽造身分的前提下,讓每次 Tool Call 留下一條完整的責任鏈。