本文是「Agent 工程实战」系列的第 20 篇,属于工具链线。
系列索引(21 篇阅读路线):Agent 放权前,先补权限、出站、发现这三道口子
摘要:给 MCP Server 加 token 校验就安全了吗?我搭了两个 Server 做对照实验:一个只验签名、一个还校验 audience(这个 token 是发给谁的),用同一个"签名有效但 aud 指向别处"的 token 分别请求——只验签名的返回 200 放行并建会话,校验 aud 的返回 401 拒绝。输出是本地真实跑出来的。这轮复盘:token 校验不是"收到就信",是有效、aud、scope 三件事都要对。
文章目录
- 那天我对着一个 401 想了一会儿
- 实验:同一个 token,两个 Server,两种结局
- 排查:为什么"只验签名"拦不住
- 根因:信任边界错位
- 解决:token 校验的三件事,一件都不能少
- 边界:这个实验说明了什么、没说明什么
- 回头看你该怎么验自己的 Server
- 附:实验脚本
- issue-token.js —— 签发"签名有效但 aud 指向别处"的 token
- server-B.js —— 错误版:只验签名
- server-A.js —— 正确版:验签 + 校验 aud
- common.js —— 两个 Server 共用的 MCP HTTP 骨架
- test.js —— 发请求,打印两个 Server 的响应
- 跑法
那天我对着一个 401 想了一会儿
把 MCP Server 从本地 stdio 包成 HTTP 服务放到服务器上的时候,我干的第一件事是加 token 校验。当时想法很简单:接口加上 Bearer token,没有 token 的请求直接 401,这不就安全了吗。
直到我把上一篇《MCP Server 生产化清单》整理完,对着官方安全文档里的 Token Passthrough 反模式发呆——官方说的是 MUST NOT 级别的要求:“MCP servers MUST NOT accept any tokens that were not explicitly issued for the MCP server.” 我意识到一个问题:我的"加了 token 校验"只回答了"这 token 是不是有效的",没回答"这 token 是不是发给我的"。
这两个问题不一样。前者是签名和有效期,后者是 audience。为了确认这个区别到底有多致命,我没有直接改代码,先设计了一个两个 Server 的对照实验,把两种校验逻辑摆在一起比。
实验:同一个 token,两个 Server,两种结局
先说实验设计,它把问题收敛得很干净:
- 签发一个 token,签名有效、没过期,但
aud故意指向一个不存在的服务https://other-service.example.com——这个 token 本来就是"发给别的服务"的。 - 搭两个 HTTP MCP Server:
- Server A:验签 + 校验 aud(token 的 aud 得是我这个 Server 的地址才放行)
- Server B:只验签 + 有效期,不校验 aud
- 拿同一个 token 分别请求两个 Server。
拓扑画出来是这样(右侧是设计预期,实测结果见下面的实验观察块):
同一个 token(签名有效,aud 指向别的服务) │ ▼ ┌─────────────────────────────────────────┐ ▼ ▼ Server B(:3000) Server A(:3001) 只验签名,不校验 aud 验签名 + 校验 aud 预期:放行 预期:拒绝为什么要看这张图:它把变量控制住了——两个 Server 唯一的不同就是"有没有校验 aud",token 又是同一个。实验结果如果有差异,差异只能来自 aud 校验这一件事。
实验脚本我放在文章最后(Node 18+ 直接跑,依赖express @modelcontextprotocol/sdk jsonwebtoken zod,SDK 版本会自动用上当前最新协议版本)。我先坦白一句:这次实验我真的搭环境跑了一轮——Node 25.8.2 + SDK 1.30.0,下面是真实的终端输出,不是推导。两个 Server 的校验逻辑差异是确定的:Server B 只调了jwt.verify,token 签名有效它就必然放行;Server A 在 aud 不匹配时必然 throw,中间件把异常转成 401。想亲眼验证的,照附录脚本跑一遍就行,脚本是完整的。
【实验观察 · 真实输出(Node 25.8.2 + @modelcontextprotocol/sdk 1.30.0,2026-09 实测)】
===== Server B:只验签名(漏 aud)(端口 3000)===== HTTP 状态码:200 响应头 Mcp-Session-Id:(有,每次运行随机) 响应前 300 字符:event: message data: {"result":{"protocolVersion":"2025-11-25","capabilities":{"tools":{"listChanged":true}},"serverInfo":{"name":"order-demo","version":"1.0.0"}},"jsonrpc":"2.0","id":1} ===== Server A:验签 + 校验 aud(端口 3001)===== HTTP 状态码:401 响应头 Mcp-Session-Id:(无) 响应前 300 字符:{"error":"aud 不匹配:期望 http://localhost:3001,实际 https://other-service.example.com"}实测结果很明确:同一个 token,B 放行、A 拒绝。如果你自己跑出来的状态码跟这不一样,大概率是 SDK 版本或环境问题——把输出贴出来,一起对一下。
把两个 Server 的差异放进一张表看,就是校验项目从上到下比一遍:
| 校验维度 | Server A(正确版) | Server B(错误版) |
|---|---|---|
| 验签 + 有效期 | ✅ 校验 | ✅ 校验 |
| 检查 token 的 aud 是不是发给我的 | ✅ 校验,不匹配就拒绝 | ❌ 不检查 |
| 对"aud 指向别处"的 token | 401 拒绝,无会话 | 200 放行,还会发 Mcp-Session-Id 建会话 |
| 结果 | 该挡的挡住了 | 把自己当成"谁都能进的公共门" |
排查:为什么"只验签名"拦不住
顺着 Server B 的校验逻辑往回看,问题很清楚:
// Server B 的校验:只验签名 + 有效期functionverifyTokenB(token){constpayload=jwt.verify(token,SECRET);// 签名对、没过期returnpayload;// ← 没有检查 payload.aud}这段逻辑回答的问题是"这个 token 是不是伪造的"。答不上来的问题是"这个 token 是不是该用在我的 Server 上"。
// 这段逻辑对"aud 指向别处"的 token 的判定结果: 签名有效 + 未过期 → jwt.verify 放行 → 返回 payload → Server B 直接处理请求(上方实测输出里那行 200 + Mcp-Session-Id)而 MCP Server 的真实处境是:它不是只有你一个调用方。云上跑着一堆 agent,客户端拿到的 token 可能来自各种授权服务器。Server 校验的时候只问"有没有效",等于默认了"只要有效就能进"——但有效的 token 可能是发给隔壁服务的,也可能是带了一堆超出本服务需要的权限。
根因:信任边界错位
这个实验暴露的不是一个代码 bug,是信任边界的错位。
把"token 有效"当成"可以放行",等于把两件不同的事混成一件事:
- token 本身可信(签名对、没过期)——这是密码学问题
- token 被授权给这个 Server(aud 匹配)——这是授权问题
MCP 官方把第二件事单独拎出来,命名成 Token Passthrough 反模式,原话是 MUST NOT 级别:“MCP servers MUST NOT accept any tokens that were not explicitly issued for the MCP server.” 为什么这么严重?因为 Server 一旦接受任意有效 token 并拿它去调下游 API,客户端就绕过了 Server 这层的限流、审计、访问控制,等于借了 Server 的身份去访问下游——Server 变成了被借道的代理。
用我自己的话说:门装上了,但钥匙谁都能配。
解决:token 校验的三件事,一件都不能少
对照完这两个校验逻辑,我确认自己的 Server 也得改成三件事全查:
// 正确做法:验签 + 有效期 + aud 校验functionverifyTokenA(token){constpayload=jwt.verify(token,SECRET);// ① 签名有效、没过期constaudiences=Array.isArray(payload.aud)?payload.aud:[payload.aud];if(!audiences.includes(MY_URL)){// ② 这个 token 是发给我的thrownewError(`aud 不匹配:期望${MY_URL},实际${audiences.join(', ')}`);}// ③ scope 校验(按工具类别检查,这里省略)returnpayload;}对应到官方 SDK 上,就是checkResourceAllowed那个函数做的事——token 的 aud 和 Server 地址精确匹配才放行(官方教程里就是这么用的)。
// 这段逻辑对同一个 token 的判定结果: aud 是 https://other-service.example.com,期望 http://localhost:3001 → 不匹配 → throw → 中间件把异常转成 401(上方实验观察里那行 401)再提醒一句:实验里我用的是 HS256 对称签名做演示,生产环境的授权服务器(官方教程里的 Keycloak 就是典型)一般签发非对称 JWT,验签方式和密钥管理不一样,但aud 校验的逻辑是通用的,跟签名算法无关。
边界:这个实验说明了什么、没说明什么
- 这是本地演示实验,不是线上事故复盘。真实攻击链要更复杂——攻击者得真的拿到一个"有效但不是发给你的" token(错误配置的客户端、泄露的 token、被误发的 scope 都可能成为来源)。实验只复现了最要紧的一环:缺了 aud 校验时,服务端该拒绝却不拒绝。需要说明的是,上面的输出是我本地实跑的真实终端输出(Node 25.8.2 + SDK 1.30.0),不是推导。想自己验证的直接用附录脚本,几分钟出结果;跑出来的状态码和文中不一致,大概率是 SDK 版本差异或环境问题,把输出发我一起看。
- 实验里两个 Server 只做了最小化实现(initialize + 一个工具),没覆盖完整生产功能。这不影响结论本身,但"你能复现"这件事是有边界的——如果你跑出来 Server B 也是 401,大概率是脚本问题,把输出发我一起看。
- 单一 Server 单租户的场景,aud 校验的风险没那么显眼(你自己的 token 只会发给你的 Server)。它真正要紧的是 Server 开始服务多个客户端、多个来源的 token 之后。
回头看你该怎么验自己的 Server
如果看到这里想确认自己的 MCP Server 有没有这个问题,三个问题就够了:
- 校验逻辑里有没有检查 token 的
aud字段? - 校验通过后,有没有按 scope 检查"这个 token 能不能调这个工具"?
- 日志里有没有把 Authorization 头 / token 打进去?
前两题答不上来的,参照官方 Security Best Practices 里 Token Passthrough 那节补;第三题答"打了"的,赶紧改。
附:实验脚本
五个小文件,Node 18+ 跑(package.json里设"type": "module",依赖express @modelcontextprotocol/sdk jsonwebtoken zod)。正文输出就是下面这套脚本在Node 25.8.2 + @modelcontextprotocol/sdk 1.30.0上跑出来的,你装其他 Node 18+ 版本也能跑,协议版本号会按你装的 SDK 自动取当前值。
issue-token.js —— 签发"签名有效但 aud 指向别处"的 token
importjwtfrom"jsonwebtoken";constSECRET="demo-secret-change-me";consttoken=jwt.sign({sub:"user-1",aud:"https://other-service.example.com",// 故意指向别的服务scope:"mcp:tools",},SECRET,{expiresIn:"1h"});console.log(token);server-B.js —— 错误版:只验签名
importjwtfrom"jsonwebtoken";import{createHttpApp}from"./common.js";constSECRET="demo-secret-change-me";functionverifyTokenB(token){constpayload=jwt.verify(token,SECRET);// 验签 + 有效期returnpayload;// ← 没有检查 payload.aud}createHttpApp({verifyToken:verifyTokenB,port:3000});server-A.js —— 正确版:验签 + 校验 aud
importjwtfrom"jsonwebtoken";import{createHttpApp}from"./common.js";constSECRET="demo-secret-change-me";constMY_URL="http://localhost:3001";// 我这个 Server 的地址functionverifyTokenA(token){constpayload=jwt.verify(token,SECRET);constaudiences=Array.isArray(payload.aud)?payload.aud:[payload.aud];if(!audiences.includes(MY_URL)){thrownewError(`aud 不匹配:期望${MY_URL},实际${audiences.join(", ")}`);}returnpayload;}createHttpApp({verifyToken:verifyTokenA,port:3001});common.js —— 两个 Server 共用的 MCP HTTP 骨架
importexpressfrom"express";import{randomUUID}from"node:crypto";import{McpServer}from"@modelcontextprotocol/sdk/server/mcp.js";import{StreamableHTTPServerTransport}from"@modelcontextprotocol/sdk/server/streamableHttp.js";import{isInitializeRequest}from"@modelcontextprotocol/sdk/types.js";import{z}from"zod";functioncreateMcpServer(){constserver=newMcpServer({name:"order-demo",version:"1.0.0"});server.registerTool("query_orders",{title:"Query Orders",description:"按用户查询订单列表(演示工具)",inputSchema:{userId:z.string().describe("用户 ID"),},},async({userId})=>({content:[{type:"text",text:`订单列表(模拟数据):user=${userId}, 共 2 条`},],}));returnserver;}exportasyncfunctioncreateHttpApp({verifyToken,port}){constapp=express();app.use(express.json());consttransports={};app.use("/mcp",(req,res,next)=>{constauth=req.headers.authorization||"";consttoken=auth.replace(/^Bearer\s+/i,"");if(!token)returnres.status(401).json({error:"missing token"});try{req.mcpAuth=verifyToken(token);next();}catch(err){returnres.status(401).json({error:err.message});}});app.post("/mcp",async(req,res)=>{constsessionId=req.headers["mcp-session-id"];lettransport=sessionId&&transports[sessionId];if(!transport&&isInitializeRequest(req.body)){transport=newStreamableHTTPServerTransport({sessionIdGenerator:()=>randomUUID(),onsessioninitialized:(id)=>{transports[id]=transport;},});transport.onclose=()=>{if(transport.sessionId)deletetransports[transport.sessionId];};constserver=createMcpServer();awaitserver.connect(transport);}elseif(!transport){returnres.status(400).json({error:"no session"});}awaittransport.handleRequest(req,res,req.body);});app.listen(port,()=>{console.log(`MCP Server 启动,端口${port},校验方式:${verifyToken.name}`);});}test.js —— 发请求,打印两个 Server 的响应
import{LATEST_PROTOCOL_VERSION}from"@modelcontextprotocol/sdk/types.js";const[,,token]=process.argv;if(!token){console.error("用法:node test.js <token>");process.exit(1);}constbody=JSON.stringify({jsonrpc:"2.0",id:1,method:"initialize",params:{protocolVersion:LATEST_PROTOCOL_VERSION,capabilities:{},clientInfo:{name:"audit-test",version:"1.0.0"},},});asyncfunctionprobe(name,port){constres=awaitfetch(`http://localhost:${port}/mcp`,{method:"POST",headers:{"Content-Type":"application/json",Accept:"application/json, text/event-stream",Authorization:`Bearer${token}`,},body,});consttext=awaitres.text();console.log(`\n=====${name}(端口${port})=====`);console.log(`HTTP 状态码:${res.status}`);console.log(`响应头 Mcp-Session-Id:${res.headers.get("mcp-session-id")||"(无)"}`);console.log(`响应前 300 字符:${text.slice(0,300)}`);}awaitprobe("Server B:只验签名(漏 aud)",3000);awaitprobe("Server A:验签 + 校验 aud",3001);跑法
npm init -y npm install express @modelcontextprotocol/sdk jsonwebtoken zod npm pkg set type=module 终端1:node server-B.js # 端口 3000 终端2:node server-A.js # 端口 3001 终端3:node issue-token.js # 复制输出的 JWT 终端3:node test.js <token> # 同一个 token 分别打两个 Server提醒几点:两个 Server 终端都起来后再跑
test.js,否则连不上端口会报 ECONNREFUSED;如果registerTool的 inputSchema 在你装的 SDK 版本里报错,把createMcpServer里注册那几行注释掉即可,实验结论不受影响。
「Agent 工程实战」系列
- 路线:工具链线(MCP/测试脚本)
- 上一篇:19 MCP 安全四件事
- 下一篇:21 测试脚本刷爆接口
- 系列索引(21 篇阅读路线):Agent 放权前,先补权限、出站、发现这三道口子
- 专栏:Agent 工程实战
- 如果这篇对你有用,欢迎收藏;想追更可以关注「Agent 工程实战」专栏。