補寫於 2026 年 9 月 6 日,日期掛回文章原本該發的那天。文中對 MCP 授權規格的描述以撰寫當日的官方規格文件為準。

整份 MCP 授權規格繞著一件事打轉:它要你把一把完全合法的 token 丟掉。

簽章驗得過、還沒過期、scope 也完全對得上,你的 server 照樣得回它 401。

會撞到這條,通常是因為你要把一台本來只跑在本機的 MCP server 拉出來對外開。這時候最省事的接法有兩種。一種是把 client 送來的 Authorization: Bearer 標頭原封不動往下游 API 轉,反正都是同一家 IdP 簽的;另一種是只要簽章驗得過就收,不管這把 token 當初是發給誰的。規格把這兩種接法都判死了,而且用的是 MUST NOT。

你在驗票,規格要你驗的是收件人

大部分人對 OAuth 的心智模型是驗票。票沒被竄改、還在效期內、座位等級對得上,放行。

audience 這一格問的是另一件事:這張票當初是不是賣給我這一場的。防偽做得再漂亮、日期也對、座位等級也符合,但票面印的是隔壁廳那場,驗票員該不該放人進來?直覺會覺得「反正都是我們家發的票」,規格的答案是不行。

四條 MUST 講同一件事,重點在最後兩個字

MCP Specification 2026-07-28 的 Authorization 章節,Access Token Usage 底下的 Token Handling 一口氣排了四條,原文逐字:

MCP servers, acting in their role as an OAuth 2.1 resource server, MUST validate access tokens as described in OAuth 2.1 Section 5.2.
MCP servers MUST validate that access tokens were issued specifically for them as the intended audience, according to RFC 8707 Section 2.
If validation fails, servers MUST respond according to OAuth 2.1 Section 5.3 error handling requirements. Invalid or expired tokens MUST receive a HTTP 401 response.

MCP clients MUST NOT send tokens to the MCP server other than ones issued by the MCP server’s authorization server.

MCP servers MUST only accept tokens that are valid for use with their own resources.

MCP servers MUST NOT accept or transit any other tokens.

前面幾條講的是怎麼驗:驗有效性、驗 audience、驗不過要回 401。真正把路封死的是最後那句的 accept or transit。規格不只禁止你收下一把不是發給你的 token,連「轉手」都一起禁。你不能說我沒有真的信任它、我只是幫忙轉一下。

那下游 API 怎麼呼叫

這是實作的時候第一個會卡住的地方。MCP server 常常就是個中間人,使用者要它去 Notion、去 GitHub、去公司內部的訂單系統撈東西。手上明明就有一把使用者的 token,不准往下游丟,那要拿什麼去打?

Security Considerations 的 Access Token Privilege Restriction 直接回答了:

If the MCP server makes requests to upstream APIs, it may act as an OAuth client to them. The access token used at the upstream API is a separate token, issued by the upstream authorization server. The MCP server MUST NOT pass through the token it received from the MCP client.

正解是兩把 token。client 到 MCP server 一把,MCP server 到上游 API 另一把,中間由 MCP server 自己用 OAuth client 的身分去換。不是轉手,是換發。

Security Best Practices 把 token passthrough 列成 anti-pattern,給了四類風險。最容易被低估的是稽核那一類,原文寫:「The downstream Resource Server’s logs may show requests that appear to come from a different source with a different identity, rather than the MCP server that is actually forwarding the tokens.」下游 log 上看到的身分是使用者,不是那台真正在轉發的 server。同一段還有更狠的一句:「a malicious actor in possession of a stolen token can use the server as a proxy for data exfiltration.」你的 server 成了別人偷資料的跳板,而下游還查不到你。

另外兩類,一類是 Security Control Circumvention,rate limiting、request validation、traffic monitoring 這些控制往往綁在 token 的 audience 上,轉傳等於幫 client 繞過去;一類是 Future Compatibility Risk,原文很有畫面:「Even if an MCP Server starts as a “pure proxy” today, it might need to add security controls later.」

送一個對方可能根本不看的參數

audience 要驗得起來,前提是 token 裡真的有 audience。這件事得由 client 在換 token 的時候就講清楚要換給誰用,也就是 RFC 8707 的 resource 參數。規格對 client 一樣排了三條 MUST:

  1. MUST be included in both authorization requests and token requests.
  2. MUST identify the MCP server that the client intends to use the token with.
  3. MUST use the canonical URI of the MCP server as defined in RFC 8707 Section 2.

長相是這樣:

1
&resource=https%3A%2F%2Fmcp.example.com

接著是本篇第二個反直覺的地方。規格下一句寫:

MCP clients MUST send this parameter regardless of whether authorization servers support it.

即使你的授權伺服器根本不看這個參數,client 還是必須送。一般規格不會要求你送一個對方可能直接丟掉的東西。這樣寫是為了讓生態的發送端先到位,等授權伺服器哪天補上支援,audience 綁定就能自動生效,不必回頭改所有 client。

canonical URI 的合法與不合法,規格自己列了範例。https://mcp.example.com/mcphttps://mcp.example.comhttps://mcp.example.com:8443 都合法,https://mcp.example.com/server/mcp 在需要區分個別 server 的時候也合法。不合法的是 mcp.example.com(missing scheme)跟 https://mcp.example.com#fragment(contains fragment)。尾斜線比較微妙:https://mcp.example.com/https://mcp.example.com 在 RFC 3986 底下技術上都是合法的絕對 URI,但實作 SHOULD 一致採用不帶尾斜線的那一種。記住這個尾斜線,等一下還會再出現。

規格花了一整節講一種攻擊,四個條件要同時成立才會中招:MCP proxy server 對第三方授權伺服器用的是一組 static client ID;它同時允許 MCP client 動態註冊,每個 client 拿到自己的 client_id;第三方授權伺服器在第一次授權後會種一個 consent cookie;而這台 proxy 在轉給第三方之前,沒有做逐個 client 的同意確認。

流程規格寫了八步,骨幹是這樣。使用者先正常走完一次授權,第三方授權伺服器在瀏覽器上留下一個 cookie,記著「這組 static client ID 已經同意過了」。之後攻擊者送一個惡意連結給使用者,裡面帶著攻擊者自己動態註冊的 client_id 跟攻擊者的 redirect URI。使用者一點,瀏覽器上還帶著上次那個 cookie,授權伺服器看到就跳過同意畫面,授權碼直接被導到攻擊者的 server 上。

問題不在 cookie,在同意的粒度。使用者第一次同意的對象是那組 static client ID,cookie 記的也是它,所以換一個 client 進來,授權伺服器眼中還是「同一個已經同意過的 client」。規格對 consent cookie 開的條件裡,最後一項把整件事講完了:用 __Host- 前綴、設 SecureHttpOnlySameSite=Lax、加密簽章或改用 server-side session,然後 bind to the specific client_id,not just “user has consented”。

順帶一提,動態註冊正是上面四個條件之一,而規格在 Overview 已經把 Dynamic Client Registration(RFC7591)標記為 deprecated,只為了跟不支援 Client ID Metadata Documents 的授權伺服器相容而保留。一邊要求逐個 client 確認同意,一邊把製造這個問題的機制降級,方向是一致的。

規格寫 MUST,官方 SDK 預設不驗

讀完整份規格之後,我覺得最值得講的其實是這一層。

modelcontextprotocol/python-sdk 現在 24,208 顆星、MIT 授權,最後一次 push 是 2026-09-05,最新 release 是 v2.1.1(2026-08-25 發布),專案活得很好(以上查詢日為 2026-09-06)。打開 src/mcp/server/auth/middleware/bearer_auth.py,第 51 行:

1
def __init__(self, token_verifier: TokenVerifier, *, resource_server_url: AnyHttpUrl | None = None):

第 74 行:

1
if self.resource_server_url and not self._issued_for_this_resource(auth_info.resource):

resource_server_url 預設是 None。第 74 行前半是 if self.resource_server_url and ...,你沒傳這個參數,整段 audience 比對就被短路跳過。也就是說,照 SDK 最簡寫法架起來的 MCP server,預設不做規格要求的 audience 驗證。

官方範例也是同一個調性。examples/servers/simple-auth/mcp_simple_auth/token_verifier.py 的建構子帶著 validate_resource: bool = False,程式碼裡的註解寫得很白:# RFC 8707 resource validation (only when --oauth-strict is set)。照規格驗這件事,被放在一個叫 --oauth-strict 的旗標後面。嚴格模式不是預設模式。

倒也不是說 SDK 不在乎。真的把門打開之後,比對是 fail-closed 的:

1
2
3
4
5
6
7
def _issued_for_this_resource(self, resource: str | None) -> bool:
"""Compare as URLs (so case and default-port spelling do not matter), a trailing slash aside."""
try:
token_resource = str(AnyHttpUrl(resource or ""))
except ValidationError:
return False
return token_resource.removesuffix("/") == str(self.resource_server_url).removesuffix("/")

token 沒帶 resource、或帶了一個解析不出來的字串,except ValidationErrorreturn False,一律當不合格。該嚴的地方是嚴的,只是那道門預設沒關上。

為什麼要這樣設計,官方沒有明說。我的推測是大量既有的授權伺服器根本不支援 RFC 8707 的 resource 參數,發出來的 token 裡不會有正確的 aud;SDK 如果預設開啟嚴格驗證,這些 server 一升級就整批掛掉。規格那句「clients MUST send this parameter regardless of whether authorization servers support it」正好是為這個過渡期寫的,先讓發送端到位,驗證端才有東西可以驗。這段是推測,我沒有在官方文件找到明文說明。

推測歸推測,同一個 repo 裡有一個一手細節是確定的,值得抄走。src/mcp/shared/auth_utils.py 做階層比對時,會先把兩邊的路徑都補上尾斜線再比:

1
2
3
4
5
6
# Normalize trailing slashes before comparison so that
# "/foo" and "/foo/" are treated as equivalent.
...
# Check hierarchical match: requested must start with configured path.
# The trailing-slash normalization ensures "/api123/" won't match "/api/".
return requested_path.startswith(configured_path)

註解自己點名了那個坑。如果直接拿裸的 startswith 做階層比對,一把發給 https://x.com/api 的 token 就能拿去存取 https://x.com/api123。差一個 123,就是越權。前面規格要你尾斜線寫法一致,坑就長在這裡。

拿得到那串 ID,不等於通過驗證

同一條思路在 2026-07-28 這版還長出另一個新的 MUST NOT。規格把協定層的 session 拿掉了:

MCP is stateless and has no protocol-level sessions. Servers that need state spanning multiple requests mint an explicit handle, such as a shopping cart ID or a workflow ID, and receive it back as an ordinary tool argument on each request.

購物車 ID、工作流 ID 這種東西,現在就是一個普通的工具參數,跟著每次請求送回來。於是規格得補一句:MCP servers MUST NOT treat possession of a state handle as authentication,而且實作了授權的 server MUST verify all inbound requests,每一次都驗。

要怎麼綁,規格給了可以直接抄的做法:把狀態的 key 設成 <user_id>:<handle>,而那個 user ID 要從驗過的 token 推出來,不能由 client 自己填。舊版(2025-11-25 及更早)這一節叫 Session Hijacking,換了名字,但要防的是同一件事:光是「手上有那串 ID」什麼都不能證明。

401 要回得像一份說明書

規格要求 MCP server MUST 實作 RFC 9728 的 Protected Resource Metadata,client 也 MUST 靠它去找授權伺服器。所以驗不過的那個 401,不是回一個 Bearer 就算數,裡面要帶指路的 metadata 位址:

1
2
3
HTTP/1.1 401 Unauthorized
WWW-Authenticate: Bearer resource_metadata="https://mcp.example.com/.well-known/oauth-protected-resource",
scope="files:read"

執行期權限不夠是另一個狀態碼,403 不是 401:

1
2
3
4
5
HTTP/1.1 403 Forbidden
WWW-Authenticate: Bearer error="insufficient_scope",
scope="files:write",
resource_metadata="https://mcp.example.com/.well-known/oauth-protected-resource",
error_description="File write permission required for this operation"

連請求本身寫壞了該回什麼,規格也一併列進同一張表:

Status Code Description Usage
401 Unauthorized Authorization required or token invalid
403 Forbidden Invalid scopes or insufficient permissions
400 Bad Request Malformed authorization request

/.well-known/oauth-protected-resource 這個路徑站上寫過一次,那篇談的是客戶端怎麼找到授權伺服器的探索順序。這篇是從 server 這一側看同一個路徑:它是你回 401 的時候必須交出來的東西。

什麼情況下這些可以先放著

先講清楚這篇的邊界。整篇是讀規格與官方 SDK 原始碼寫出來的,我沒有實際架過一台對外的 MCP server,也沒有打過任何一個 .well-known/oauth-protected-resource 端點。SDK 那一段我只讀了 Python SDK,TypeScript SDK 的預設值我沒查,別把結論套到它身上。另外,規格與 best practices 描述的攻擊都是假想情境,我沒有查到任何對應的 CVE 編號。

我的立場是這樣:只要你的 MCP server 有一個對外的 HTTPS 端點,而且會代使用者去呼叫下游 API,那條 MUST NOT pass through 就是第一優先,排在 scope 設計跟 rate limit 前面。

會讓我改口的條件也很明確。你的 server 走 stdio 在本機跑,規格自己說 SHOULD NOT follow this specification,憑證從環境變數拿就好,這整套跟你無關。授權在 MCP 裡本來就是 OPTIONAL,沒做授權不算違規,只是沒有保護。內網、單一 client、下游只有你自己那個服務,這些先放著,等要對外開的時候再回來補。

有幾件事也別期待過高。audience 綁定擋不住 mix-up 攻擊,規格自己寫「Resource indicators do not help when the attacker’s authorization server is intercepting requests before they hit the honest authorization server.」PKCE 也救不了,因為「the client transmits the code_verifier to the attacker’s token endpoint」。那要靠 RFC 9207 的 iss,而規格緊接著補了一句:「it provides no protection against an honest server that does not [emit iss]」。誠實的授權伺服器不送 iss,這條防線就不存在。

比這些更基本的一件是:resource 送出去,不代表 token 真的被綁定。規格限定得很清楚,RFC 8707 帶來的好處是 when the Authorization Server supports the capability。對方不支援,你照送,token 照樣沒有 audience,這是規格自己承認的過渡狀態。順手記一個小地雷,SDK 範例的註解提醒 aud may be a string or a list,只寫 data["aud"] == my_url 的實作會在陣列那種情況誤判。

兩種 URI 比對,兩套相反的規矩

規格裡有一組對照很值得記。比對 canonical resource URI 的時候,要你把 scheme 跟 host 轉小寫、把 fragment 清掉,先正規化再比。但比對 RFC 9207 的 iss 的時候,規格用 MUST NOT 禁止正規化:不准做 scheme 或 host 的大小寫折疊、不准省略預設 port、不准動尾斜線、不准做百分比編碼的正規化,要逐字元相同才算過。

同一份規格,對兩種 URI 比對給了完全相反的規定。原因是它們防的東西不一樣。canonical URI 是求互通,拼法上的差異不該把人擋在門外;iss 是防冒充,任何一點差異都值得懷疑。

這就是我讀完會帶走的東西。下次要比對一個字串之前,先問這次比對是在求互通還是在防冒充,答案會直接決定你該不該正規化,也會決定 fail-closed 要不要打開。