一个能复现的越权调用
设想一家公司把三台远程 MCP Server 挂在同一个授权服务器下:知识库检索服务、代码仓库服务、财务报销服务。员工在 MCP 客户端里先登录知识库,拿到一个访问令牌。客户端随后把同一个令牌发给财务服务的 MCP 端点,请求调用“提交报销单”工具。财务服务返回了成功,而不是 401。
这个结果违反直觉。用户只授权了知识库,财务服务却接受了他的请求。问题不在网络,也不在 TLS,而在令牌本身:财务服务没有检查这个令牌是不是为它签发的。
OAuth 2.0 的访问令牌默认是持有者令牌(bearer token)。RFC 8707 明确指出,任何持有该令牌的一方都能用它访问相关资源。要阻止误用,必须满足一个前提:令牌只对特定受保护资源、特定访问范围有效。当授权服务器不知道客户端打算把令牌用在哪里,它就无法把令牌的受众限制到那个资源。
本文以“三台 MCP Server 共用同一授权服务器”为贯穿场景,说明受众绑定为什么失效、如何校验、以及生产环境该观察哪些信号。
基线做法为什么会漏
授权服务器不知道令牌要去哪
在没有资源指示器的情况下,客户端向授权服务器发授权请求,只带 client_id、scope、redirect_uri 等参数。授权服务器看到 scope 是 kb:read,就按这个范围签发令牌。它不知道这个令牌接下来会被送到知识库、代码仓库还是财务服务。
RFC 8707 的引言把这个问题讲得很清楚:当授权服务器服务大量不同资源时,客户端需要显式告诉它令牌将用于哪里。知道接收方之后,授权服务器才能按该实体构造令牌,例如把令牌内容加密给特定资源,或按该资源的数据要求生成令牌。
scope 不能替代资源身份
一种常见做法是拿 scope 编码服务位置,比如 finance:submit。RFC 8707 指出这种做法并不总是可行或合适。scope 通常表达“请求什么访问”,而不是“在哪里兑换这个访问”。email、admin:org、user_photos、channels:read 这类 scope 值只传达访问类型,不传达位置或身份。
把服务名塞进 scope 会带来两个后果。第一,scope 命名空间随服务数量膨胀,授权服务器和客户端都要维护映射。第二,即便 scope 里写了 finance,如果资源服务器不校验令牌受众,另一个服务仍可能接受这个令牌。scope 是权限声明,不是接收方声明。
资源服务器默认信任任何有效令牌
许多 MCP Server 的实现只做两件事:验证令牌签名、检查是否过期。签名有效且未过期,就放行。这种校验回答了“令牌是不是这个授权服务器签的”,但没有回答“这个令牌是不是签给我用的”。
RFC 8707 引用了 RFC 6750 第 5.2 节的要求:应在令牌中包含预期接收方,以防止令牌重定向。如果资源服务器不检查这个接收方,令牌重定向就无人拦截。
资源指示器与 aud 绑定机制
resource 参数如何流动
RFC 8707 定义了一个 resource 请求参数。客户端在向授权服务器发请求时带上它,用来指示正在请求访问的目标服务或资源。该参数的值必须是一个绝对 URI,不得包含片段组件;通常不应包含查询组件,但在某些场景下查询组件是必要且有用的。
MCP 规范对这一点有强制要求:MCP 客户端必须实现 RFC 8707 资源指示器,显式指定令牌所请求的目标资源。MCP 服务器必须实现 RFC 9728 受保护资源元数据,客户端必须用它来发现授权服务器。
在贯穿场景里,流程是这样:客户端要连财务 MCP Server,先向该服务器的元数据端点取回受保护资源元数据,从中读到 authorization_servers 字段,得知共用授权服务器的地址。然后客户端在授权请求里带上 resource=https://finance.example.com/mcp。授权服务器据此签发一个受众为财务服务的令牌。
aud 声明承载接收方
授权服务器把 resource 值映射为令牌里的受众声明。在 JWT 形式的访问令牌中,这个声明就是 aud。资源服务器收到令牌后,取出 aud,与自己的资源标识符比对。相等才放行。
MCP 规范在安全考量里专门列了“令牌受众绑定与校验”一节,说明受众绑定是防止令牌被挪用到其他服务的关键。RFC 9728 定义了受保护资源的资源标识符,它是一个使用 http 或 https 方案的 URL,资源服务器据此发布自己的元数据。
这里有一个容易踩的坑:资源标识符和实际请求 URL 可能不同。财务服务的资源标识符可能是 https://finance.example.com/mcp,而客户端实际请求的路径可能带查询参数或子路径。校验时应该比对规范化后的资源标识符,而不是拿原始请求 URL 做字符串相等。
校验发生在哪一层
受众校验属于资源服务器的职责,发生在令牌验证之后、执行业务逻辑之前。它不依赖授权服务器端的额外配合,只要令牌里带了 aud,资源服务器就能自行判断。
下面用流程图展示一次越权调用是如何被拦下的。
flowchart TD
A[客户端已持有知识库令牌] --> B[向财务 MCP 端点发请求]
B --> C[财务服务验证令牌签名与有效期]
C -->|通过| D[读取令牌 aud 声明]
D --> E{aud 是否等于财务资源标识符}
E -->|否| F[返回 401 并附带 WWW-Authenticate]
E -->|是| G[检查 scope 是否覆盖所需工具]
G -->|通过| H[执行提交报销单工具]
G -->|不足| I[返回 403 insufficient_scope]
关键转折点在 E 节点。签名校验通过不代表可以放行,因为签名只证明令牌来源,不证明令牌归属。很多实现恰恰跳过了这一步。
三类失效成因
授权服务器未按资源签发
授权服务器收到授权请求后,如果忽略 resource 参数,或者虽然收到但没有把它写进令牌的 aud,签出的令牌就没有接收方信息。此时资源服务器即使想校验也无从校验。
这种失效通常有几个诱因:授权服务器版本较旧,不支持 RFC 8707;或者支持但配置里没有开启资源指示器处理;或者客户端根本没传 resource,授权服务器只能签一个受众宽泛的令牌。MCP 规范要求客户端必须传,所以第三种情况属于客户端实现缺陷。
客户端复用令牌
客户端可能把为一个服务获取的令牌缓存起来,连另一个服务时直接复用。在贯穿场景里,客户端先连知识库拿到令牌,再连财务服务时复用了同一个令牌,而不是重新走一次带 resource 的授权流程。
Cloudflare 的 MCP 客户端实现文档提到,其 MCPClientManager 为每个连接维护独立的认证上下文,允许一个智能体同时对多个服务器完成认证。这说明多服务器场景下,逐连接维护认证状态是可行的工程做法。客户端复用一个令牌,等于把知识库的授权范围扩散到了财务服务。
资源服务器不校验 aud
这是最直接的一环。资源服务器验证了签名和有效期就放行,没有读取 aud。即便授权服务器正确签发了带受众的令牌,这一步缺失也会让绑定失效。
MCP 规范在安全考量中把“令牌受众绑定与校验”单列,正是因为资源服务器端的校验不能省略。签名验证和受众校验回答的是两个不同问题,前者防伪造,后者防挪用。
方案对比与权衡
静态 API Key 是最简单的替代方案。每台 MCP Server 发一个长期密钥,客户端按服务配置。它不依赖授权服务器,实现成本低。代价是密钥长期有效、难以按用户粒度撤销、无法表达用户级授权,而且一旦泄露,影响范围覆盖该密钥对应的全部权限。
逐服务令牌方案要求客户端为每个 MCP Server 单独走授权流程,令牌受众绑定到该服务。它的授权粒度更细,能按用户、按服务、按 scope 撤销。代价是客户端要管理多个令牌的生命周期,授权服务器要正确处理 resource 参数,资源服务器要校验 aud。
下表对比三种形态在贯穿场景下的表现。
| 维度 | 静态 API Key | 共用令牌不校验 aud | 逐服务令牌并校验 aud |
|---|---|---|---|
| 令牌接收方绑定 | 无,密钥即权限 | 无,令牌可跨服务使用 | 有,aud 绑定到具体资源 |
| 越权调用风险 | 密钥泄露即全量暴露 | 高,A 服务令牌可调 B 服务 | 低,跨服务调用被 401 拦截 |
| 撤销粒度 | 按密钥,影响该密钥全部权限 | 按令牌,但受众宽泛 | 按用户、按服务、按 scope |
| 客户端复杂度 | 低,配置密钥即可 | 低,复用令牌 | 中,需按服务管理授权流程 |
| 授权服务器要求 | 无 | 无 | 需支持 RFC 8707 资源指示器 |
| 资源服务器要求 | 校验密钥 | 校验签名与有效期 | 校验签名、有效期与 aud |
| 用户级授权 | 不支持 | 部分支持 | 支持 |
这张表里最后两行的差异决定了方案能否落地。逐服务令牌方案要求授权服务器和资源服务器同时支持受众绑定,任何一端缺失,绑定都会退化。
实现校验与可观测指标
资源服务器侧的校验顺序
资源服务器收到请求后,按固定顺序校验,任一步失败立即拒绝。
第一步,验证令牌签名,确认由可信授权服务器签发。第二步,检查 exp 和 nbf,确认令牌在有效期内。第三步,读取 aud,与自身的资源标识符比对。第四步,检查 scope 是否覆盖被调用工具所需的权限。
第三步是本文重点。比对时要注意 aud 可能是字符串或数组。RFC 8707 允许客户端一次请求多个资源,此时 aud 可能是数组。资源服务器应判断自身资源标识符是否在数组内,而不是要求数组只有一个元素。
// 伪代码:资源服务器受众校验
function authorize(request, token, myResourceId):
if not verifySignature(token):
return reject(401, "invalid_token")
if isExpired(token) or notYetValid(token):
return reject(401, "invalid_token")
audiences = normalizeAudience(token.aud)
if myResourceId not in audiences:
return reject(401, "invalid_token")
if not scopeCovers(token.scope, request.tool):
return reject(403, "insufficient_scope")
return allow()
这段逻辑里,normalizeAudience 负责把字符串或数组统一成集合,myResourceId 是资源服务器在 RFC 9728 元数据里声明的资源标识符。两者必须来自同一套配置,否则会出现“自己签的令牌自己都不认”的故障。
客户端侧的令牌隔离
客户端应按 MCP Server 隔离令牌存储,键可以是服务器标识符或资源标识符。连新服务器时,先查是否有该资源的有效令牌,没有就重新走带 resource 的授权流程。不要用“当前用户已登录”作为复用令牌的依据。
需要观察的信号
受众校验失败应该产生可区分的错误,而不是笼统的 401。建议在日志和指标里记录以下内容。
- 按资源标识符统计的受众校验失败次数。持续增长说明有客户端在复用令牌。
- 授权请求里
resource参数的缺失率。缺失率高说明客户端未实现 RFC 8707。 - 签出令牌的
aud分布。如果大量令牌aud为空或为宽泛值,说明授权服务器未按资源签发。 - 同一令牌在不同资源标识符上的出现次数。跨资源出现即越权信号。
- 401 响应中
WWW-Authenticate头的返回率。MCP 规范要求服务器在返回 401 时用该头指示资源元数据 URL。
这些指标能区分三类成因:resource 缺失率指向客户端,aud 分布指向授权服务器,受众校验失败次数指向资源服务器或复用行为。
失效边界与未决问题
受众校验不是万能的。它依赖授权服务器正确签发带 aud 的令牌。如果授权服务器不支持 RFC 8707,资源服务器拿不到可校验的受众信息,只能退回到“校验签名加有效期”的弱校验。此时越权风险仍然存在,需要靠客户端隔离令牌来缓解。
另一个边界是资源标识符的规范化。同一台服务器可能通过多个 URL 访问,比如带不带尾斜杠、走不走网关。如果资源标识符配置不一致,合法请求会被误拒。生产环境应固定一套资源标识符,并在网关层做重写,避免多套标识符并存。
仍未解决的问题包括:多资源令牌的受众数组如何与 scope 精确对应,目前规范没有给出强绑定;令牌内省(token introspection)场景下 aud 如何传递,需要资源服务器与授权服务器约定;以及当 MCP Server 动态注册、资源标识符频繁变化时,客户端缓存如何失效。这些边界在部署前需要结合自身授权服务器能力逐项确认。