---
title: "MCP 服务器必须检查令牌是给谁的 — FoxAuth"
description: "通俗讲解 MCP 授权：MCP 服务器必须发布什么，令牌里哪个字段必须指明它，以及规范留给你的两个问题。"
canonical: "https://foxauth.dev/zh-cn/blog/your-mcp-server-is-an-oauth-resource-server/"
lastmod: "2026-10-08T21:45:57+03:00"
---
博客
MCP 服务器必须检查令牌是给谁的
通俗讲解 MCP 授权：MCP 服务器必须发布什么，令牌里哪个字段必须指明它，以及规范留给你的两个问题。
作者 FoxAuth · 2026-09-30

这个令牌是为内部 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 Unauthorized

WWW-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 返回什么。

全部文章亲自试试
