# 微服務安全：從 API Gateway 到微服務間的授權防禦體系

## 前言

「API 有 OAuth2、服務都在 Kubernetes 叢集內」常被當成安全的終點，但有兩種攻擊會直接穿過這層防線：被入侵節點的橫向移動，以及 OWASP 列為 API 頭號風險的 BOLA。

本文用一個常見的 BFF（Backend for Frontend）架構，依序回答四個問題：

1. 一個節點被駭入後，攻擊者能走多遠？
2. 合法的 OAuth2 access token 能不能擋住 BOLA？
3. 服務間 mTLS 能防到哪裡？
4. IETF 正在制定的 Transaction Tokens（Txn-Token）能再補上多少？

## 一個節點被駭，攻擊者能走多遠

爆炸半徑不是被攻破的那個服務，而是它直接與間接能連到的所有服務。

典型架構是這樣的：BFF 是唯一對外的入口，背後呼叫一群內部服務，內部服務之間也互相呼叫。叢集內部預設互信，只要網路連得到就能呼叫。

BFF 一旦被攻破，攻擊者就繼承了 BFF 在叢集內的身分與網路可達性。接著每個被觸及的服務都能當成下一個跳板，一路從訂單、庫存、支付走到帳務服務與資料庫。下圖中紅色虛線是攻擊者從 BFF 出發的橫向移動路徑：只要服務間預設互信，每個被觸及的服務都會成為下一個跳板。

![](https://cdn.hashnode.com/uploads/covers/6420f5cbbdbe7d697133d12a/a0f43b2b-df31-4359-9419-13be87292ee2.png align="center")

## BOLA：合法 token 擋不住的漏洞

OAuth2 access token 擋不住 BOLA，因為它證明的是「你是誰、能做哪一類操作」，而 BOLA 出問題的地方是「你能存取哪一筆資料」。

BOLA（Broken Object Level Authorization）在 OWASP API Security Top 10 2023 版排名第一。看一個典型情境：

1. Alice 拿著合法的 token（scope 是 `orders:read`）呼叫 `GET /orders/1001`，正常。
2. 她把 ID 改成 `1002`，那是 Bob 的訂單。
3. token 簽章正確、沒過期，scope 也符合，每一關都通過。
4. API 沒有檢查訂單 1002 的 owner 是不是 token 的 `sub`，於是回傳了 Bob 的資料。

scope 表達的是「可以讀訂單」，不是「可以讀哪幾張訂單」。把物件清單塞進 token 也不實際：資料會變動，token 會變肥。就算用 Rich Authorization Requests（RFC 9396）描述更細的權限，最後還是要由 resource server 自己執行檢查。

簡單說，OAuth2 是必要條件，不是充分條件。

![](https://cdn.hashnode.com/uploads/covers/6420f5cbbdbe7d697133d12a/4969b5b5-ff6c-450b-858c-fe5a1ef00409.png align="center")

### 延伸觀念：BFLA、BOLA 與 BOPLA 的防線分工

把 BOLA 放回 OWASP API Security Top 10 的脈絡來看會更清楚。OWASP 把授權漏洞拆成三個維度，各自對應到不同的防禦邊界：

| 弱點名稱（OWASP Top 10） | 核心維度 | 檢驗問題 | 典型情境 | 本文防線定位 |
| :-- | :-- | :-- | :-- | :-- |
| **BFLA**<br>(API5:2023 Function Level) | **垂直（功能級）** | 誰能執行這項功能？ | 一般使用者直接發送 `POST /api/admin/users/delete`，後端沒擋權限直接刪除帳號。 | **OAuth2 Scope 可攔截**：只要 API Gateway 或服務檢查 Scope / Role 即能阻斷。 |
| **BOLA**<br>(API1:2023 Object Level) | **水平（物件級）** | 誰能存取這筆資料？ | Alice 持合法 Token 將 `GET /orders/1001` 改為 `1002`，成功看到 Bob 的訂單。 | **OAuth2 / mTLS 盲區**：需靠微服務查詢時強制比對 `owner_id = sub`。 |
| **BOPLA**<br>(API3:2023 Property Level) | **屬性（欄位級）** | 誰能讀取/修改物件的特定屬性？ | 1\. **過度暴露**：API 回傳了不該給的 `password_hash`。<br>2\. **大量指派/竄改**：更新時偷塞 `"role": "admin"`，或中繼節點竄改轉帳帳號。 | **Txn-Token** `tctx` **可鎖定交易屬性**：用簽章防止中途竄改；讀取面則靠 DTO 白名單。 |

![](https://cdn.hashnode.com/uploads/covers/6420f5cbbdbe7d697133d12a/cd9b0fd3-0cb0-4185-a82c-b0eb3f66faf6.png align="center")

拆開來看就能發現：OAuth2 Scope 本質上是防 BFLA（能不能打這支 API），看不見資料歸屬；Txn-Token 則進一步把 `sub` 與 `tctx` 交易參數綁死帶進內部，幫忙擋掉中途的身分冒充與參數竄改（BOPLA）。至於 BOLA，最後一哩路還是得落在微服務查資料庫的那一刻（`WHERE owner_id = sub`）。

## 在 BFF 架構中，兩種風險會疊加

最危險的寫法是「BFF 驗完 token，內部服務就完全信任 BFF」。

常見做法是 BFF 用自己的 service account 呼叫內部服務，把 `userId`、`orderId` 放在 header 或 query 裡轉發，內部服務不再檢查。這會產生兩個後果：

- BFF 直接轉發使用者給的 ID，BOLA 就穿透到內部服務。
- 任一節點被入侵後，攻擊者能帶著任意 `X-User-Id` 呼叫其他服務，內部服務無從分辨請求是不是真的代表那個使用者。

兩者會互相放大：BOLA 讓合法使用者越權，橫向移動讓入侵者冒充任何使用者。

![](https://cdn.hashnode.com/uploads/covers/6420f5cbbdbe7d697133d12a/6ed4b973-adfb-4b74-b5d4-0c563d0d1761.png align="center")

## mTLS：證明「呼叫者是哪個 workload」

mTLS 能把橫向移動的範圍縮小到合法的呼叫路徑，但它只認得 workload、不認得使用者，所以對 BOLA 沒有幫助。

mTLS 讓連線兩端都出示憑證、互相驗證，並加密服務間流量。在 Kubernetes 上，每個 workload 通常會拿到一個與 ServiceAccount 綁定的身分，例如 SPIFFE ID `spiffe://prod.internal/ns/shop/sa/bff`。

### 光開 mTLS 還不夠

mTLS 本身只做認證，不做授權。只開 mTLS 的話，任何持有合法憑證的 workload 仍然可以呼叫任何服務，上面那張圖的紅色虛線一條都不會少。

要搭配呼叫政策，明確寫出「誰能呼叫誰」。以 Istio 為例，先用 `PeerAuthentication` 的 `STRICT` 模式強制 mTLS，再用 `AuthorizationPolicy` 依來源身分放行：

```yaml
apiVersion: security.istio.io/v1
kind: AuthorizationPolicy
metadata:
  name: order-service-allow-bff
  namespace: shop
spec:
  selector:
    matchLabels:
      app: order-service
  action: ALLOW
  rules:
  - from:
    - source:
        principals: ["prod.internal/ns/shop/sa/bff"]
```

一個 workload 只要套用了 ALLOW 政策，沒有符合任何規則的請求就會被拒絕。這樣一來，只有實際需要的呼叫邊會留下來。

### mTLS 的天花板

被入侵的 BFF 呼叫訂單服務，是一條「本來就被允許」的路徑，身分完全合法，政策攔不下來。mTLS 也不知道這通呼叫代表哪位使用者，攻擊者在合法路徑上帶任意 `X-User-Id`，對方一樣無從分辨。

換句話說，mTLS 回答「誰在打電話」，下一節的 Txn-Token 回答「這通電話代表哪位使用者的哪筆交易」。兩者互補；Txn-Token 草案本身也要求 workload 與 TTS 之間雙向認證，並建議使用 mTLS 或 SPIFFE 這類機制。

![](https://cdn.hashnode.com/uploads/covers/6420f5cbbdbe7d697133d12a/3d314f8a-4555-4f30-89c4-6cdc15b684ce.png align="center")

## Transaction Tokens 是什麼

Txn-Token 是一張短效、有簽章的 JWT，把「誰、要做什麼、在什麼情境下」不可篡改地帶過整條服務呼叫鏈。

它是 IETF OAuth 工作小組的草案 [draft-ietf-oauth-transaction-tokens](https://datatracker.ietf.org/doc/draft-ietf-oauth-transaction-tokens/)。撰文時為 draft-11（2026-07-30），工作小組已達成共識、等待 write-up，預計 2026 年 12 月送 IESG。它還不是 RFC，欄位名稱在定稿前仍可能變動。

### 用看診來理解

把一次 API 請求想成到醫院看診，對應關係是這樣的：

- **健保卡是 access token。** 掛號櫃台（對外入口）驗證後，健保卡就不再往後傳。
- **醫囑單是 Txn-Token。** 醫生（TTS）開出一張蓋了院內防偽章的醫囑單，寫明病人是誰、這次要做哪些檢驗或給哪些藥，只限這次看診有效。
- **醫囑單在各科室之間流轉。** 檢驗科、放射科、藥局就是內部服務，每個科室都先核對印章，再照單執行。病人不需要，也不能修改這張單。
- **沒有人能中途改單。** 藥局不能把病人名字換掉（`sub`），檢驗科也不能自己多加項目（`tctx`）。

這個比喻也剛好說明了 Txn-Token 的三個侷限：

- **醫生開錯藥，醫囑單還是會照樣執行。** 印章保證內容沒被改，不保證內容是對的，這就是它擋不住 BOLA 的原因。
- **防偽章被偷，蓋出來的單看起來都是真的。** TTS 和它的簽章金鑰是整套機制的根本。
- **醫囑單可以被影印重用，直到過期。** Txn-Token 無法偽造，但拿到的人就能在有效期內重放。

![](https://cdn.hashnode.com/uploads/covers/6420f5cbbdbe7d697133d12a/f2e25c47-668c-4eab-9a86-cdcb141ba565.png align="center")

### 運作流程

![](https://cdn.hashnode.com/uploads/covers/6420f5cbbdbe7d697133d12a/73f3f80d-c1a4-4477-8324-8f1909d7791c.png align="center")

- 外部用戶端帶著 access token 呼叫對外入口（BFF 或 API gateway）。
- 入口用 OAuth 2.0 Token Exchange（RFC 8693）向信任域內唯一的 Transaction Token Service（TTS）換一張 Txn-Token，雙方必須相互認證。
- TTS 簽發短效（分鐘級或更短）、`typ` 為 `txntoken+jwt` 的 JWT。
- 之後每一跳都用 `Txn-Token` HTTP header 原封不動往下傳。規格刻意不用 `Authorization` header，因為那個位置可能另有服務間授權用途。
- 每個收到請求的服務自己驗簽章、`aud` 與 `exp`，並依 `tctx` 檢查參數與執行授權，再決定要不要放行。Txn-Token 絕不回傳給外部用戶端。

透過這套換票機制，原本用於南北向的外部憑證，在進入叢集邊界後就轉換為東西向專用的交易憑證：

![](https://cdn.hashnode.com/uploads/covers/6420f5cbbdbe7d697133d12a/9f7dcc96-c87d-4a39-9408-f5efad4b9dc9.png align="center")

### 常見 Token 屬性與 Claims 解析

在探討 Txn-Token 之前，先快速梳理現代 Token（JWT / OAuth2 / OIDC）中最常見的標準屬性（Claims）。如果把 Token 想成一張**數位通行證**，這些屬性回答了幾個根本問題：

#### 1\. RFC 7519 標準 Claims（身分與生命週期）

| Claim | 全名 | 白話意義 | 資安防護重點 |
| :-- | :-- | :-- | :-- |
| `iss` | Issuer（簽發者） | 是誰發的？ | **防止假冒機構**：接收端必須比對 `iss` 是否為信任的 IdP/TTS，並以此拉取對應的 JWKS 公鑰驗簽。 |
| `sub` | Subject（主體） | 代表誰？ | **唯一身分標識**：代表使用者或背景服務，應使用不可變的內部唯一 ID（如 UUID），而非可變的帳號或 Email。 |
| `aud` | Audience（受眾） | 這張票給誰看的？ | **防橫向轉發（極為關鍵）**：限制只有目標信任域或服務能收。若不檢查，被竊取的 Token 容易被轉發攻擊其他無關服務。 |
| `exp` | Expiration Time（過期時間） | 何時失效？ | **限縮外洩窗口**：以 Unix Timestamp 標記。Txn-Token 要求短效（分鐘級甚至秒級），降低重放利用空間。 |
| `iat` | Issued At（簽發時間） | 何時發的？ | **判定權限新鮮度**：協助驗證 Token 年齡，亦可用於強制註銷（如拒絕密碼變更前簽發的所有 Token）。 |
| `nbf` | Not Before（生效時間） | 何時才能用？ | 提早發放但指定時限後才啟用的預約票券（一般即時請求通常省略）。 |
| `jti` | JWT ID（唯一流水號） | 每張票的防偽編號 | **防止重放攻擊（Replay Attack）**：接收端可在快取中記錄已出現過的序號，阻斷短時間內的重複請求。 |

#### 2\. OAuth 2.0 / OIDC 常見授權屬性

- `scope`**（權限範圍）**：描述「被授權執行哪一類操作」（例如 `orders:read`）。但它只定義粗粒度的動作類型，無法約束細緻到「能存取哪一筆資料」（這就是為什麼光靠 scope 擋不住 BOLA）。

  > **實務補充：Scope 的命名與治理** RFC 6749 其實沒有限制 Scope 的字串格式，只規定用空格分隔。但實務上若放任各微服務自己取名，很容易撞名（例如兩個服務都定義 `read`）。比較穩健的做法是由 API Gateway 或授權中心集中維護 Scope 清單（Scope Registry），並採用 `<資源>:<動作>`（如 `orders:read`）的前綴慣例來隔離命名空間；跨服務時也可搭配 [RFC 8707 (Resource Indicators)](https://www.rfc-editor.org/rfc/rfc8707) 限制 Token 的適用受眾，避免一張 Token 塞滿所有服務的權限。
- `client_id` **/** `azp`**（Authorized Party）**：標記當初是「哪一個前端 Client / App」（如行動 App 或 Web SPA）替使用者發起換票。

#### 3\. Txn-Token 的專屬 Claims

IETF Transaction Tokens 在標準 JWT 的基礎上，額外擴充了微服務內部最缺乏的**交易脈絡**：

| Claim | 全名 | 用途 |
| :-- | :-- | :-- |
| `txn` | Transaction ID | 整條微服務呼叫鏈的唯一交易識別碼（由 TTS 簽章背書，相當於具密碼學保證的進階 `jti`）。 |
| `tctx` | Transaction Context | 整條鏈路上**不可篡改的業務參數**（例如 `order_id`、`tenant_id`），直接由 TTS 簽章鎖定。 |
| `rctx` | Request Context | 請求當下的環境資訊（例如原始客戶端 IP `req_ip`、認證強度 `authn: mfa`）。 |
| `req_wl` | Requesting Workload | 向 TTS 申請這張 Token 的工作負載身分（例如 `bff`），記錄最初是由誰帶入叢集。 |

一張包含標準身分與交易脈絡的 Txn-Token payload 大致如下：

```json
{
  "iss": "https://tts.prod.internal",
  "aud": "prod.internal",
  "sub": "alice",
  "iat": 1793750100,
  "exp": 1793750400,
  "txn": "97053963-...",
  "scope": "orders.read",
  "req_wl": "bff",
  "tctx": { "order_id": "1002" }
}
```

> \[!NOTE\] **跨信任域考量**：Txn-Token 的 `aud` 限制其只能在當前信任域內流通，不可跨域攜帶。若呼叫鏈涉及跨信任域，草案建議改用正在制定中的 [draft-ietf-oauth-identity-chaining](https://datatracker.ietf.org/doc/draft-ietf-oauth-identity-chaining/) 進行身分與授權串聯。

### 其他場景

`tctx` 和 `scope` 裡要放什麼，草案沒有規定，由各自的信任域決定。下面四個例子示範不同場景的寫法，欄位名稱皆為示意。

**轉帳：鎖住金額與收款人**

```json
{
  "aud": "prod.internal",
  "txn": "5f1c2a90-...",
  "sub": "alice",
  "scope": "transfer.create",
  "req_wl": "bff",
  "rctx": { "req_ip": "203.0.113.10", "authn": "mfa" },
  "tctx": {
    "from_account": "acct-001",
    "to_account": "acct-778",
    "amount": "5000",
    "currency": "TWD"
  }
}
```

途經的風控、帳務、通知服務看到的是同一組金額與收款帳戶，被入侵的節點無法把 `to_account` 換成自己的帳戶。風控服務也能直接讀 `rctx.authn`，確認這筆轉帳有經過多因素驗證。這本質上就是在微服務鏈路中，利用密碼學鎖死交易關鍵欄位，徹底防範中繼節點發起的 BOPLA（屬性竄改 / Mass Assignment）。

![](https://cdn.hashnode.com/uploads/covers/6420f5cbbdbe7d697133d12a/87c8ca2f-3897-4ed7-959a-bf06a3b4cff8.png align="center")

**多租戶 SaaS：把租戶邊界帶進每一跳**

```json
{
  "aud": "prod.internal",
  "txn": "c03e7d11-...",
  "sub": "bob@acme",
  "scope": "files.download",
  "req_wl": "api-gateway",
  "tctx": {
    "tenant_id": "acme",
    "file_id": "f-3391"
  }
}
```

儲存、掃毒、稽核等下游服務都用 `tctx.tenant_id` 過濾資料，不必相信請求參數裡的租戶 ID。但這仍然不能取代 BOLA 檢查：檔案服務還是要確認 `f-3391` 屬於 `acme`，而且 bob 有權下載。

**列表與搜尋情境（List / Batch Read）：鎖定查詢邊界**

```json
{
  "aud": "prod.internal",
  "txn": "d8e31a22-...",
  "sub": "alice",
  "scope": "orders.list",
  "req_wl": "bff",
  "tctx": {
    "tenant_id": "acme",
    "department_id": "sales",
    "max_limit": 50
  }
}
```

面對 `GET /orders?limit=50` 這種沒有單一資源 ID 的列表或批量撈取請求，`tctx` 鎖定的是**查詢邊界與過濾維度**。需要特別留意的是：

- `max_limit` **必須由 TTS 依政策設定上限**（例如強制限制最多 50 筆），不能直接照抄呼叫端的請求參數；否則被攻破的 BFF 只要申請 `max_limit: 100000`，TTS 就會忠實簽出。
- `department_id` **等權限屬性應由 TTS 從身分目錄或授權系統計算出來**，而非來自請求參數（草案範例即包含這類由 TTS 自行計算、不在原始請求中的值）。

下游資料服務將 `tctx` 的過濾條件強制注入 SQL 的 `WHERE` 與 `LIMIT` 子句中，即可**防止上游節點竄改查詢邊界**（例如把條件竄改為 `ALL` 或惡意拉大分頁範圍）。但請注意：持有資料庫憑證的服務一旦被入侵，Txn-Token 無法保護資料庫本身，仍需靠資料庫帳號權限最小化與存取稽核。

**內部排程：沒有使用者請求的交易**

```json
{
  "aud": "prod.internal",
  "txn": "8a62b4e5-...",
  "sub": "invoice-job",
  "scope": "invoice.generate",
  "req_wl": "billing-scheduler",
  "tctx": {
    "billing_period": "2026-10",
    "tenant_id": "acme"
  }
}
```

每月產生帳單的排程沒有外部 access token，可以改用自簽 JWT（`self_signed`）向 TTS 申請。這種申請方式的 `sub` 是由申請者自己決定的，所以 TTS 的簽發政策要嚴格限制：只有這個排程 workload 能用，而且只能拿到 `invoice.generate`。

### TTS 怎麼驗證申請者

TTS 會分開檢查兩件事：「誰在申請」與「申請的是誰的交易」。自簽 JWT 只負責後者，它是 workload 用自己的金鑰簽的，單獨拿出來沒有證明力；真正的信任來自申請者的身分認證，再加上 TTS 的簽發政策。

![](https://cdn.hashnode.com/uploads/covers/6420f5cbbdbe7d697133d12a/e596b8c6-e912-46d4-af5d-800cc9ad408d.png align="center")

**第一步：雙向認證。** 草案要求 TTS 與申請的 workload 必須互相認證，並建議用非對稱方式，例如 mTLS client 認證（RFC 8705）或簽章的 client assertion（RFC 7523），憑證可由 SPIFFE 發放。以帳單排程為例，SPIRE 給 `invoice-job` 發一張 X.509-SVID，排程用它以 mTLS 連到 TTS，TTS 驗證憑證鏈後就知道「打進來的是 invoice-job」。反方向同樣重要：workload 也要驗證 TTS 的伺服器憑證，並只從可信來源取得 TTS 位址，避免把 token 送給冒牌的 TTS。

**第二步：送出申請。** 排程先做一張自簽 JWT，草案要求它包含 `iss`（申請的 workload）、`sub`（交易主體）、`aud`（TTS 本身）、`iat` 與 `exp`：

```json
{
  "iss": "spiffe://prod.internal/ns/billing/sa/invoice-job",
  "sub": "invoice-job",
  "aud": "https://tts.prod.internal",
  "iat": 1793750400,
  "exp": 1793750460
}
```

用自己的 SVID 私鑰簽好後，透過 mTLS 連線送出 token exchange 請求（實際送出時參數需 URL encoding，這裡為了好讀省略）：

```http
POST /token HTTP/1.1
Host: tts.prod.internal
Content-Type: application/x-www-form-urlencoded

grant_type=urn:ietf:params:oauth:grant-type:token-exchange
&requested_token_type=urn:ietf:params:oauth:token-type:txn_token
&audience=prod.internal
&scope=invoice.generate
&subject_token=<自簽 JWT>
&subject_token_type=urn:ietf:params:oauth:token-type:self_signed
&request_details={"billing_period":"2026-10","tenant_id":"acme"}
```

**第三步：TTS 依序檢查。**

1. 申請者是誰：從 mTLS 憑證得知是 `invoice-job`，無法認證就拒絕。
2. 自簽 JWT 是否有效：用該 workload 的公鑰驗簽，確認 `aud` 是自己、`iat` 與 `exp` 合理。實務上還應確認 `iss` 與 mTLS 認證出來的身分一致，避免 A 拿著 B 簽的 JWT 來申請；這一條草案沒有逐字規定，但很建議加上。
3. 簽發政策：這個 workload 能不能用 `self_signed`、能用哪些 `sub`、能拿到哪些 `scope`。
4. 全部通過才簽發 Txn-Token，並把 `req_wl` 設成 `invoice-job`。

第 3 步是關鍵。草案規定，如果 TTS 無法從 subject token 或其他可信來源判斷原始權限範圍，就必須拒絕。自簽 JWT 本身沒有 scope，所以這個可信來源只能是 TTS 自己的政策。政策格式由各實作決定，以下僅為示意：

```yaml
- workload: spiffe://prod.internal/ns/billing/sa/invoice-job
  subject_token_types: [self_signed]
  allowed_sub: [invoice-job]
  allowed_scope: [invoice.generate]
```

如果 `invoice-job` 被入侵，攻擊者能拿到合法憑證，也能自己簽 JWT，TTS 擋不住它申請，只能靠政策限制它拿得到什麼。`sub` 鎖死成自己、`scope` 只有 `invoice.generate`，它最多就只能產帳單。最危險的設定，是允許某個 workload 用 `self_signed` 填入任意使用者的 `sub`，那等於讓它可以冒充任何人。

草案也特別強調：Txn-Token 不是 access token，也不是 workload 的認證憑證；而且禁止把使用者原本的 access token 放進 Txn-Token 裡。

## Txn-Token 能擋什麼、擋不住什麼

Txn-Token 主要對準**橫向移動**，對 BOLA 只補上一半。

### 對 BOLA：可信的身分，但不判斷意圖合不合法

它解決了前面提到的「轉發 `X-User-Id`」問題：內部服務拿到的是有簽章、不可篡改的 `sub`，中途節點也無法偷換 `tctx` 裡的 `order_id`。

但如果 Alice 一開始就請求 Bob 的訂單 1002，TTS 只會忠實地簽出「alice 要讀 1002」。Txn-Token 保證的是意圖沒被篡改，而「服務如何根據 token 做授權」被草案明確列為規格範圍外。訂單服務仍然要檢查 `owner_id = sub`，只是現在能用可信的身分來做。

### 防禦後的架構對照

當我們把 **mTLS + 授權政策**、**Txn-Token** 以及 **服務層物件授權** 分層疊加後，前面那張橫向移動與越權的攻擊圖就被收斂成如下的零信任防線：

![](https://cdn.hashnode.com/uploads/covers/6420f5cbbdbe7d697133d12a/3f4a5125-d5cc-4e05-8e65-2a60242fd0a7.png align="center")

綠色實線代表具備身分與交易脈絡的合法通訊；紅色虛線則是原本在缺乏防禦時會成功的橫向移動與越權路徑。在加入防禦後，這些攻擊大多被 mTLS 呼叫政策、Txn-Token 參數錨定以及物件授權攔截（註：路徑 ② 假設 BFF 業務上不需直接呼叫支付服務；若業務需要則仍為合法連線，政策無法攔阻，需仰賴支付服務本身的驗證；重放攻擊與入口節點淪陷風險依然存在，需搭配進一步配套）。

### mTLS 與 Txn-Token 逐項對照

| 情境 | mTLS + AuthorizationPolicy | Txn-Token |
| --- | --- | --- |
| 不在允許清單上的 workload 直接呼叫服務 | 擋得住 | 擋得住，沒有合法 Txn-Token 就不被接受 |
| 被入侵的服務沿著允許的路徑憑空發起呼叫 | 擋不住，身分合法 | 擋得住，沒有進行中的交易就沒有 token |
| 內部節點偽造 `X-User-Id` 冒充別人 | 擋不住，不認得使用者 | 擋得住，`sub` 有簽章 |
| 中途節點篡改請求參數 | 擋不住 | 擋得住，前提是下游以 `tctx` 為準，或比對請求參數與 `tctx` |
| 竊聽或篡改服務間流量 | 擋得住，加密且有完整性保護 | 不處理，依賴傳輸層 |
| 從 token 裡挖出使用者的 access token 去打別的 API | 不處理 | 擋得住，規格禁止放入 access token |
| 被入侵節點在有效期內重放手上的 Txn-Token | 擋不住，若路徑被允許 | 只能縮小範圍：短效期、窄 `scope`，用 `txn` 偵測或一次性檢查 |
| 使用者本人改 ID 存取別人的資料（BOLA） | 擋不住 | 擋不住，仍需物件層級授權 |
| BFF 本身被入侵 | 部分：只能打 BFF 被允許呼叫的服務 | 部分：仍需有效 subject token；若允許 `self_signed` 或 `unsigned_json`，就能替任意使用者申請 |

重放那一項要特別注意：一次性檢查需要各副本共享狀態，實務上不一定做得到。

## 實務建議

沒有單一機制能同時解決兩個問題：BOLA 靠每個服務的物件層級授權，橫向移動靠分層的服務間信任。

### 物件層級授權

- 由擁有資料的服務自己檢查，不要只靠 BFF 或 gateway。subject 只從驗證過的 token 取，不從 path、body 或自訂 header 取。
- 把擁有權條件寫進查詢，不要先撈出資料再比對（見下方範例）。
- 找不到或無權限都回 404，避免洩漏資源是否存在。
- 分享、角色、組織階層等複雜關係，可以考慮 OPA、OpenFGA（Zanzibar 式 ReBAC）或 Cedar，但呼叫檢查的責任仍在各服務。
- UUID 只能增加猜測難度，ID 很容易從其他 API 回應、log 或分享連結外洩。
- 用兩個測試帳號做交叉存取的自動化測試。

```go
claims := auth.ClaimsFrom(ctx) // 來自已驗證的 Txn-Token

// 單一資源查詢：強制比對擁有者
order, err := repo.GetOrder(ctx, orderID, claims.Subject)
// SQL: WHERE id = $1 AND owner_id = $2（多租戶再加 tenant_id）
if errors.Is(err, repo.ErrNotFound) {
    return http.StatusNotFound
}

// 列表與批次查詢：由 tctx 強制帶入查詢邊界與分頁上限，防止上游節點竄改條件
orders, err := repo.ListOrders(ctx, claims.TxnContext.TenantID, claims.TxnContext.MaxLimit)
// SQL: WHERE tenant_id = $1 ORDER BY created_at DESC LIMIT $2
```

### 接收端驗證清單

呼叫鏈上的內部服務收到請求時，應落實以下防禦檢查清單：

1. **驗證簽章與演算法**：以 TTS 的公開金鑰集（JWKS）驗簽，嚴格限制只接受預期的非對稱演算法（如 `ES256`、`RS256`），防止演算法降級攻擊（如 `none` 或非對稱轉對稱攻擊）。
2. **檢查 Token 類型（**`typ`**）**：確認 Header 中的 `typ` 欄位為 `txntoken+jwt`，避免把外部 OAuth2 Access Token 或內部一般 JWT 誤當作 Txn-Token 放行。
3. **驗證有效性與信任邊界**：確認 `aud` 為本地信任域（如 `prod.internal`），且當前時間介於有效期間內（檢查 `iat` 與 `exp`，確認未過期）。
4. **比對操作權限（**`scope`**）**：檢查 token 內的 `scope` 是否涵蓋當前 API 所欲執行的動作。
5. **校驗交易脈絡（**`tctx`**）**：業務邏輯應直接取用 `tctx` 內的不可變參數（如 `tenant_id`、`order_id`），或比對 HTTP 請求參數與 `tctx` 是否完全一致；若不一致必須立即拒絕。
6. **安全轉發與去識別化記錄**：往下一跳轉發時以原樣傳遞 `Txn-Token` Header。禁止在日誌中輸出 Token 原文，僅記錄其 Hash 或關鍵 Claims。

### 服務間信任的三層

| 層次 | 回答的問題 | 常見做法 |
| --- | --- | --- |
| 網路與呼叫政策 | 誰能呼叫誰 | NetworkPolicy 預設拒絕、Istio AuthorizationPolicy |
| Workload 身分 | 呼叫者是哪個 workload | mTLS、SPIFFE |
| 交易脈絡 | 這次呼叫屬於哪位使用者的哪筆合法交易 | Txn-Token |

把這三層信任加上服務自己的物件授權，單一微服務收到請求時就會依序通過四道關卡：

![](https://cdn.hashnode.com/uploads/covers/6420f5cbbdbe7d697133d12a/f649b67d-0cd8-46d0-9116-7cf6a42d512b.png align="center")

### TTS 簽發政策

- 限制哪些 workload 能申請 Txn-Token，以及能用哪種 subject token。`self_signed` 與 `unsigned_json` 只開給真正需要的排程類 workload。
- `scope` 不得超過原始 token；無法判定原始 scope 時，草案要求直接拒絕。
- **替換 Txn-Token（Token Replacement，草案 Section 13.15）**： 將 Txn-Token 當作 `subject_token` 再次向 TTS 換發時，草案語氣偏向保守，強調 TTS 必須特別謹慎，因為頻繁替換可能抵銷 Txn-Token 原有的防禦意圖。
  - **延伸應用情境（Fan-out 縮權）**：當呼叫鏈分岔時，上游服務可作為一種可能的延伸應用方式（常被非正式稱為巢狀或衍生 Token），拿現有 Txn-Token 向 TTS 申請一張 `scope` 更窄（例如給支付服務只保留 `payments.charge`）或在 `tctx` 補充不可變計算結果的子代 Token 給特定下游。
  - **草案的硬性約束**：核心交易脈絡（`txn`、`sub`、`aud`）必須保持一致且不得改動、權限只能縮小不能擴大；**已過期的 Txn-Token 絕對禁止換發（MUST NOT）**；TTS 應嚴格限制替換鏈的深度（Depth limit）；並且 TTS 必須保留原呼叫鏈的申請者歷史紀錄（Audit Trail）。

### 觀測與偵測

- **以已驗簽的** `txn` **作為關聯鍵，比** `trace_id` **更可信**： 雖然 W3C Trace Context 的 `trace_id` 能串起分散式呼叫鏈，但 `trace_id` 是由呼叫端任意產生、且**未經數位簽章保護**（任何中繼節點都能輕易偽造或重設，即便在 mTLS 加密傳輸下亦然）。相比之下，Txn-Token 的 `txn` 是由 TTS 簽章背書的唯一交易識別碼。
  - 建議**由「接收並驗簽成功」的那一方記錄** `txn`，避免未經驗證的上游隨意宣稱。
  - 資安鑑識時，可將服務端記錄的 `txn` 與 **TTS 的簽發紀錄（或 Token Hash）進行交叉比對**，建立高可信度的交易因果軌跡。

![](https://cdn.hashnode.com/uploads/covers/6420f5cbbdbe7d697133d12a/5e43a6d7-6336-42a1-a3d1-88be87325586.png align="center")

- **個資與日誌隱私考量**： 不要記錄 Txn-Token 原文，只記 hash 或去掉簽章的 payload。若要將 `sub` 或 `rctx.req_ip` 寫入 OpenTelemetry Span Attributes，考量其可能在 Tempo 或 Jaeger 等追蹤後端長期留存且缺乏行級遮蔽，屬於個資（PII），建議呼應草案隱私考量章節，改記 Hash 或假名化 ID。
- **告警檢查**：同一 subject 短時間大量 404 或 403、連號 ID 掃描、同一 `txn` 出現在不合理的呼叫路徑或時間點。

### 導入成本與架構權衡

多加一層 Token 交換，工程上勢必得付出代價。實際落地時通常會遇到三個問題：

- **熱路徑（Hot Path）的延遲與單點故障**：TTS 卡在所有外部請求的第一線，如果每次呼叫都要跨網路去向 TTS 換票，TTS 很容易變成效能瓶頸與 SPOF。草案本身允許透過本機介面取票，實務上也建議把 TTS 做成 Sidecar 或 Node Agent（例如跟 Envoy 或 SPIRE Agent 綁在一起），改走本機 IPC / UDS 來省掉網路 Round-trip 開銷。

![](https://cdn.hashnode.com/uploads/covers/6420f5cbbdbe7d697133d12a/51b36443-0c05-47d5-82d6-e7f9b07c7dac.png align="center")

- **多語言驗簽 SDK 與金鑰輪替**：內部每個服務都要驗簽，代表大家都要處理 JWKS 快取與 Key Rotation。如果沒有提供統一的內部 SDK 或透過 Sidecar 統一處理，各團隊自己手刻驗證邏輯反而容易踩坑。
- **草案還在演進**：目前 `draft-ietf-oauth-transaction-tokens` 還沒正式成為 RFC，欄位細節仍可能微調，實作時建議把解析邏輯封裝起來，保留一點抽換彈性。

## 結語

OAuth2 access token 解決的是入口的認證，不是「這筆資料是不是你的」，也不是「這通內部呼叫是不是真的代表使用者」。mTLS 搭配呼叫政策，把爆炸半徑縮到合法的呼叫圖；Transaction Tokens 再把它縮到正在進行的那筆交易。但物件層級授權仍然是每個服務自己的責任，這兩者只是讓這個檢查終於有可靠的依據。

## 參考資料

- [draft-ietf-oauth-transaction-tokens-11: Transaction Tokens](https://datatracker.ietf.org/doc/draft-ietf-oauth-transaction-tokens/)
- [draft-ietf-oauth-identity-chaining: OAuth Identity and Authorization Chaining](https://datatracker.ietf.org/doc/draft-ietf-oauth-identity-chaining/)
- [RFC 8693: OAuth 2.0 Token Exchange](https://www.rfc-editor.org/rfc/rfc8693)
- [RFC 8707: Resource Indicators for OAuth 2.0](https://www.rfc-editor.org/rfc/rfc8707)
- [RFC 9396: OAuth 2.0 Rich Authorization Requests](https://www.rfc-editor.org/rfc/rfc9396)
- [OWASP API Security Top 10 2023: API1:2023 BOLA](https://owasp.org/API-Security/editions/2023/en/0xa1-broken-object-level-authorization/)
