博客
MCP 服务器必须检查令牌是给谁的
通俗讲解 MCP 授权:MCP 服务器必须发布什么,令牌里哪个字段必须指明它,以及规范留给你的两个问题。
作者 FoxAuth ·
这个令牌是为内部 wiki 签发的,却打开了 MCP 服务器上的所有工具。
这台 MCP 服务器的做法和大多数初次尝试一样。它下载了身份提供商的公钥;身份提供商就是签发令牌的那个服务。它校验了令牌的签名和过期时间。它从令牌里读出 subject,也就是令牌所指的那个用户,然后认定请求已通过认证。每一项检查都通过了,因为令牌是真的。对 wiki 来说,它是一个完全合格的令牌。但 MCP 服务器从来没有问过:这个令牌是给谁的?所以,任何人只要持有这个提供商签发的任意令牌,不管它是为什么签发的,都能列出这些工具并调用它们,身份就是令牌里写的那个人。
MCP 授权规范要防止的正是这个漏洞。看清这个漏洞之后,规范的其余部分就不再像一套新协议了。MCP 服务器就是一个普通的 OAuth 2.1 资源服务器:它接收令牌,并据此让调用方使用自己的东西(这里就是它的工具)。规范额外做的,只是把发现(discovery)步骤写了下来:一个只知道服务器 URL 的客户端,如何一步步拿到令牌。我在另一侧花了不少时间,也就是编写授权服务器,即签发令牌的服务器。我的总结是:MCP 授权就是三份文档加一个声明(声明就是令牌里的一个字段,claim)。几乎所有真正的问题都出在这一个声明上。
从一个 URL 到一个令牌:三份文档和一个声明
智能体宿主是 AI 智能体运行所在的程序;用 OAuth 的话说,它就是客户端。它只知道一件事:你的 MCP 服务器的 URL。从这个 URL 出发,它要弄清三件事:哪个授权服务器在保护你,怎样和那个服务器通信,以及怎样向它表明自己的身份。然后,它要带着一个只给你、不给任何别人的令牌回来。前三个问题由文档回答。最后这一点,靠的是令牌里的一个声明。
第 2 步是普通资源服务器本来不会做的。规范把它定为 MUST。它所指向的元数据文档(第 3 步)和受众检查(第 9 步)也同样是 MUST。受众就是令牌里的 aud 声明:它写明令牌是签发给哪台服务器的。第 2 步本身只有两行。如果请求没有带令牌,或者带的令牌被你拒绝,你就返回 401,并说明你的元数据在哪里。这种响应叫作质询(challenge):服务器在 WWW-Authenticate 头里告诉客户端怎样获得访问权限:
HTTP/1.1 401 UnauthorizedWWW-Authenticate: Bearer resource_metadata="https://mcp.example.com/.well-known/oauth-protected-resource", scope="mcp:tools-basic"这份元数据是 RFC 9728 定义的一份 JSON 文档,叫作受保护资源元数据(protected resource metadata)。MCP 服务器在里面说明,是哪个授权服务器在保护它:
{ "resource": "https://mcp.example.com/mcp", "authorization_servers": ["https://auth.example.com/acme"], "scopes_supported": ["mcp:tools-basic"], "bearer_methods_supported": ["header"]}从这里开始,就是授权服务器和客户端的事了。客户端从文档中取出签发者(issuer)。签发者是授权服务器的名字,写成一个 URL;这个服务器签发的每个令牌都在 iss 中带着这个 URL。客户端用这个 URL 去获取授权服务器自己的元数据。获取它的标准(well-known)地址有两个:一个由 RFC 8414 定义,一个由 OpenID Connect Discovery 定义。客户端必须两个都试,因为规范只要求服务器发布其中一个。然后,客户端执行一次普通的带 PKCE 的授权码流程:它通过浏览器拿到一个授权码,再用它换取令牌;PKCE 让截获授权码的人无法使用它。在授权请求和令牌请求中,客户端都会再发送一个参数。这个参数从 2020 年起就有了 RFC,但在实践中至今一点也不普通。
resource 参数让令牌只对你的服务器有效
这个参数就是 RFC 8707(Resource Indicators)中的 resource。客户端用它说明,自己要的是给哪台服务器用的令牌。MCP 规范要求两个请求都必须带上它。正是它,把「来自该提供商的一个令牌」变成了「给这台 MCP 服务器的令牌」。授权服务器把这个 URI 作为受众(aud)写进令牌。MCP 服务器则拒绝任何受众不完全等于其自身标识符的令牌。wiki 令牌本来会在这一关被拦下,而别的任何检查都拦不住它。
下面是授权服务器在上图流程中返回的令牌,已解码:
{ "iss": "https://auth.example.com/acme", "aud": "https://mcp.example.com/mcp", "sub": "248289761001", "client_id": "https://host.example/client.json", "scope": "mcp:tools-basic", "iat": 1759226400, "exp": 1759227300, "jti": "o1S6Qw3bH9c_UZL0"}在 MCP 服务器一侧,对这个令牌的校验归结为四项检查。其中两项必须由你自己指定,因为 JWT 库猜不出它们:
import { createRemoteJWKSet, jwtVerify } from 'jose';
const keys = createRemoteJWKSet(new URL('https://auth.example.com/acme/jwks'));
export async function principalFor(authorization) { const token = authorization?.replace(/^Bearer /i, ''); if (!token) return null; const { payload } = await jwtVerify(token, keys, { issuer: 'https://auth.example.com/acme', audience: 'https://mcp.example.com/mcp' }); return { subject: payload.sub, scopes: (payload.scope ?? '').split(' ') };}签名和过期时间(exp)由库自己检查。issuer 和 audience 要由你提供,而第一次尝试漏掉的正是 audience。任何语言的任何库都做同样的四项检查。上面这段代码,实际上就是检查清单。
我花了更久才体会到的是:同一个字符串现在出现在三个地方。写下它的是三个不同的参与方,而它在三处必须完全相同。MCP 服务器在元数据中把它发布为 resource。授权服务器的运营方登记它,也就是把它注册为一个受保护资源的标识符。客户端把它作为 resource 发送,令牌则把它作为 aud 携带。比较是精确的,并且在规范化之后进行:两边的 URL 都先被改写成同一种标准写法(下文细说)。精确比较是有意为之。不做前缀匹配,也没有「父路径的令牌对子路径同样有效」。因为一个集成的令牌恰恰就是这样在另一个集成那里生效的。
规范化正是我们第一个版本出 bug 的地方。我们是通过推敲代码发现它的,测试没有发现。规范说,客户端应当写不带末尾斜杠的 URL,服务器应当接受大写的 scheme 或主机名。所以,我们让登记和传入的请求都经过同一个函数做规范化。这个函数有一个选项,用于末尾斜杠有意义的资源。bug 是这样的:登记在存储时保留了斜杠,但比较时,我们用默认选项规范化了请求。于是斜杠只从一侧被去掉了。结果,一个声明斜杠有意义的资源,塌缩成了它不带斜杠的孪生兄弟:两个不同的资源被当成了一个。两侧必须经过同一个函数、使用同样的选项。如果这听起来显而易见——对我来说也一样,只不过是在事后。
关于受众,还有一点。每个令牌都只绑定到恰好一个已登记的资源,也就是管理员注册为受保护资源的某个 API 或 MCP 服务器。正因为如此,登记资源可以安全地作为数据而不是代码存在。在我们的服务器中,登记一台 MCP 服务器就是在控制台里填一张表:一个标识符、一个在授权同意页面上显示的名称,以及这台服务器识别的 scope。scope 是令牌可以携带的一项有名字的权限,例如 mcp:tools-basic。不需要重启任何东西。每次令牌请求都会读取登记,前面没有缓存。原因是:一条登记一旦被删除,从下一个请求起就必须停止为它签发令牌,而不是等某个缓存副本过期之后。这个教训我们已经学过一次:当时一个客户端缓存把过时的客户端交给了管理控制台。没必要再学一次。
智能体用一份文档的 URL 表明身份:方便,也有风险
智能体宿主手里只有你的 MCP 服务器的 URL,别的什么都没有。你没有地方把客户端标识符(client_id)粘贴进去。规范最初给出的答案是动态客户端注册。客户端 POST 一份对自己的描述,拿回一个 client_id。授权服务器则为每一个曾经连接过的宿主保存一条记录。当前修订版的规范把这种做法标为已弃用,转而把 Client ID Metadata Documents(客户端元数据文档)放在首位。我认为这是正确的决定。同时,这也是规范中最值得为之紧张的部分。
思路是这样的:client_id 本身就是一个 HTTPS URL。这个 URL 上放着一份描述客户端的 JSON 文档,也就是它的元数据文档:
{ "client_id": "https://host.example/client.json", "client_name": "Example Agent Host", "redirect_uris": ["http://127.0.0.1:52123/callback", "https://host.example/oauth/callback"], "token_endpoint_auth_method": "none", "grant_types": ["authorization_code", "refresh_token"]}授权服务器在需要时获取这份文档,校验它,什么也不存储。没有注册端点,也没有每多一台笔记本电脑就多一行的客户端表。授权同意页面上显示的客户端名称来自这份文档。文档由客户端的运营方控制,放在一个可以证明归运营方所有的域名上。
代价是:一个陌生人,没有账户也没有任何凭据,现在就能让你的授权服务器向他选定的任意 URL 发出 HTTP 请求。针对这一点的所有防护都必须放在服务器上。我们的做法是一个只处理网络、别的什么都不管的模块。它拒绝私有地址、环回地址和链路本地地址。环回地址是本机自己的地址,例如 127.0.0.1;链路本地地址只在本地网段内有效。这个模块把地址当作数值而不是文本来比较。原因是,URL 解析器会毫不犹豫地把 IPv4 映射的 IPv6 地址(以 IPv6 形式书写的 IPv4 地址)改写成文本检查发现不了的形式。模块手动跟随重定向,并对每一跳重新检查。它读到 5 KB 就停止读取,而且有时间上限。这项功能默认关闭,原因正是本段第一句关于陌生人的那句话。
拿到文档之后,检查就很简短,每一项都封堵一种具体的伎俩。文档里的 client_id 必须与文档自己的 URL 逐字节相等,所以托管一份写着别人标识符的文档毫无用处。redirect_uris(登录后浏览器可以被送回的地址)必须存在且非空。对称的客户端密钥(客户端和服务器共享的一个密码)会被拒绝,因为放在人人可读的文档里的密钥就不再是密钥。如果客户端用密钥对认证,文档必须说明这些密钥在哪里。
我认为真正尚未解决的是环回重定向。原生智能体宿主是运行在用户自己电脑上的应用。它在 127.0.0.1 的某个端口上等待登录后的回调,所以它的文档列出一个环回重定向地址。文档能证明写它的人控制着那个域名,却无法证明本机上是哪个程序在监听那个端口。同一台机器上的攻击者可以出示真实宿主的 client_id,并在自己的端口上监听。用户在授权同意页面上看到的,却是真实宿主的名称。规范知道这一点,说服务器应当发出警告,然后就止步于此。对于普通资源,我们像所有人一样承担这一风险。
但有一个地方,这类客户端可以拿到管理权限:本服务器自己的管理 MCP 端点,也就是智能体用来管理本服务器的那个端点。在那里,我们把警告变成了硬性拦截。如果一个客户端的文档只提供环回重定向,那么在管理员接受一份书面说明之前,这个客户端不会获得许可;说明里写明了这样的文档无法证明什么。
多个团队共用一台服务器时,智能体让谁的用户登录
这是一个规范没有提出的问题。它假设一个授权服务器为一群用户保护一台 MCP 服务器。而一旦有不止一个团队在使用授权服务器,情况就不是这样了。
假设服务器承载多个租户。租户就是一个与别人共用服务器、但有自己用户的团队。这时来了一个由元数据文档标识身份的智能体宿主。它不属于任何租户,它只是一个 URL。它让谁的用户登录?我们最终确定的答案是:由资源决定。某个租户的管理员在自己的一个项目中登记了那台 MCP 服务器。所以,指明该资源的请求就让该项目的用户登录。值得精确说明一下为什么这是安全的。把这个资源和这个项目关联起来的不是客户端,而是管理员,在他自己拥有的项目里。所以 resource 参数只能在运营方已有的登记之中做选择,无法凭空创建一条新登记。
更准确地说,它只对没有控制台账户的攻击者是安全的。有账户的人可以登记任何东西。标识符在整个实例范围内唯一,谁先登记就归谁。于是,一个恶意租户可以登记别人的 MCP 服务器。真正的所有者此后每次尝试登记都会遇到冲突,永远如此。而那台 MCP 服务器的登录,会被导入恶意租户的用户桶。用户桶是本服务器对一组最终用户的称呼,它有自己的登录规则。一旦用户桶有了自己的地址,它也就成了一个独立的签发者。
第一个修复要求每条登记自证。服务器获取该资源自己的元数据,检查其中是否指明了本签发者。这是很自然的修复。但它也拒绝了所有授权服务器获取不到其元数据的 MCP 服务器:位于内网的、开发期间跑在 localhost 上的、尚未部署的。它只撑了一天。
取而代之的是命名空间,这也是我现在会推荐给任何构建这类系统的人的方案。这里的命名空间,指属于同一个签发者的全部登记。拥有独立地址的租户本身就是一个签发者,有自己的签名密钥。资源标识符只需在一个签发者内唯一。两个租户可以登记同一台 MCP 服务器,彼此永远看不到对方的登记。这是因为每次查找只读取请求所到达的那个地址的命名空间。在你自己的命名空间里做登记不需要证明,所以服务器什么也不获取:这样的登记只可能涉及你自己的用户。
真正共享的命名空间只有一个:根签发者。所有没有独立地址的租户都在它下面提供服务。根签发者只接受超级管理员的登记。原因是:在根上,资源的元数据可以说它信任这个签发者,却无法说明它属于其中哪个租户。因此在根上,任何证明都挡不住抢注,也就是某个租户抢先登记别人的标识符。所以只有超级管理员可以在根上登记。
同一处还剩下三个漏洞,都围绕已登记的资源。前两个形式相同:「调用方忘了传入资源」。两者都值得了解,因为这种设计恰恰会催生这类错误。
第一个:账户解析器(在用户登录时查找该用户的代码)去找用户的租户,与登录步骤用的不是同一个。登录步骤传入了资源,解析器却没有。症状是授权同意页面上出现 500 错误,而不是一次拒绝。
第二个:密码登录、密码重置和注册只根据客户端来选择策略。设想一个租户,它的用户只能通过上游提供商(也就是外部身份提供商)登录。借助一个已登记的资源,别人仍然可以用密码进入这个租户。
我们把资源变成必填参数,堵住了这两个漏洞。现在,漏传资源的代码无法通过类型检查。如果你只从这一节带走一条工程规则,就带走这一条:当一个参数选择的是租户时,要让人无法在不传它的情况下调用该函数。
最后一个漏洞是机器令牌。为一个人申请令牌时,这个人会给出授权同意。所以任何客户端,哪怕是不属于任何人的客户端,都可以请一个人为已登记的资源授予令牌。客户端凭据(client credentials)令牌则不同:客户端是为自己拿令牌,谁也不用问。在我们注意到之前,下面这件事是行得通的。任何租户的任何机密客户端(有自己密钥的客户端),或者一个自行注册的客户端,都可以向受害租户的令牌端点请求令牌。而且真的能拿到:受众是受害者的资源,签发者是受害者的签发者。现在,已登记资源的机器令牌只发给登记该资源的那个项目的客户端。规范里对此只字未提。它也不会提:只有多个租户共用一台服务器时,这个问题才会出现。
你列出的每个 scope 都可能授予每个智能体,所以列表要短
有一件小事让我意外。假设你的 401 响应中的质询没有带 scope。这时规范会让客户端请求你 scopes_supported 中的所有 scope,并说明这是有意为之:通用客户端无法在它不理解的名字之间做出合理选择。所以,元数据里的一长串列表并不是一份可供挑选的菜单,而是今后每个连接进来的智能体都会被授予的东西。
所以,把你的工具真正需要的 scope 放进质询。让 scopes_supported 只保留基本使用所需的最小集合。当某个特权工具第一次被调用时,再用 403 响应和 insufficient_scope 错误请求更多权限。客户端必须以质询中的 scope 为准。它还要自己记住在逐步提权(step-up,每次多要一点权限)过程中累积的 scope。正因如此,授权服务器不需要记住这些。
我们自己的管理 MCP 端点只列出 openid,别的什么都不列。它的权限来自管理员所属的组,而不是来自 scope。一个端点从不检查的 scope,没有必要公布。
规范留给你的问题:令牌格式和混淆代理人
第一个问题是令牌格式,规范刻意没有做出决定。MCP 服务器必须拒绝不是发给它的令牌,「或以其他方式验证自己是预期的接收方」。这种措辞容许两种令牌。自包含令牌就像上面的例子,自身携带所有数据,用已发布的密钥校验。不透明令牌是一串没有内容的随机字符串,要通过内省来检查,也就是去问授权服务器这个令牌是否仍然有效。我们默认使用有效期十五分钟的自包含令牌。原因是:内省要求你的 MCP 服务器持有自己的凭据,而十分钟的上手指南里容不下这一步。代价是,自包含令牌在过期之前一直有效。如果被撤销的令牌必须在几秒而不是几分钟内失效,就把资源登记为不透明格式,给你的 MCP 服务器一个自己的客户端,并为每次调用付出一次内省请求的代价。两种答案都没有错。错的是不知道自己选了哪一个。
第二个问题是混淆代理人(confused deputy)问题:受托持有他人权限的中间方,被人利用来反过来对付权限的主人。解决它不是授权服务器的事。设想一台代理第三方 API 的 MCP 服务器。它用一个固定的 client id 访问那个 API。它动态注册自己的 MCP 客户端。它还在不展示自己授权同意页面的情况下转发授权码。安全指南逐个攻击剖析的,恰恰就是这种配置。防护分两部分。第一,代理自己按客户端征求授权同意,而且要在第三方 API 的授权开始之前。第二,代理检查受众,确保它收到的令牌绝不会被原样转发出去。我描述过的规范的每一部分都对此有帮助,但没有哪一部分能替你完成这项工作。
我还不知道的是,元数据文档的信任策略在实践中会如何定下来,也就是授权服务器应当接受哪些客户端 URL。规范允许三种做法:接受任意 HTTPS client_id、只接受列表中的域名,或者做信誉检查。每一种都打开了一条不同的钓鱼途径,因为授权同意页面上的名称就是文档里写的那个名称。默认开放是让生态得以运转的前提,允许列表则是企业会想要的。我怀疑答案是两者兼有,按租户选择。这又是规范会继续留给服务器运营者的一个问题。
如果你想端到端地看清全貌,用 OAuth 保护 MCP 服务器 大约需要十分钟。它会带你走完资源登记、你的服务器发布的两样东西(元数据和 401 质询)、令牌检查,以及两种结果:一次拒绝和一次成功。决定智能体宿主如何表明身份的有两个设置:clientIdMetadataDocument.enabled 用于元数据文档,registration.enabled 用于动态注册。端点参考列出了每个发现 URL 返回什么。