
最近有个朋友找我排查线上问题他搭的MCP Server被人白嫖了。对方用匿名请求直接调他暴露出来的几个工具接口把内部数据脚本跑了一遍又一遍等日志炸了才发现。这事不怪他因为市面上大量MCP Server教程默认就是裸奔的——Model Context Protocol把AI应用和外部工具连接起来的方式确实方便但很多人忽略了一个问题MCP Server本质上是一个对外提供能力的HTTP接口它和任何后端服务一样必须做身份认证。这篇文章我不讲空泛的安全理论就聊聊MCP Server这个具体场景下认证该怎么设计、协议支持什么、踩过哪些坑以及如何从一开始就避免把服务裸奔到公网。1. 为什么MCP Server的身份认证不是可选优化项我在最开始那个例子里提到的现象不是个例。去年年底到今年MCP的生态爆发得很厉害Claude Desktop、Cursor、各类Agent框架都开始支持通过MCP接入外部数据源和工具。大家在做技术验证的时候经常是本地起一个Python脚本用stdio模式跑通就完事了。等到真正要部署到服务器、接入企业内网数据、甚至开放给多个客户端调用的时候很多人第一反应是先上线再说认证的事情直接被排到了后面。1.1 MCP Server是AI代理的手和脚理解认证为什么重要要先理解MCP Server在系统里的位置。MCP的全称是Model Context Protocol它定义了大模型应用与外部工具之间的通信标准。一个MCP Host比如Claude Desktop或者你自己写的Agent会通过协议去调用MCP Server暴露的工具、资源和提示词。这听起来很抽象换个说法就清楚了大模型本身只会生成文本真正让它动手做事的是它调用的那些工具。如果你的MCP Server暴露了一个查询订单数据的工具那么任何能访问到这个Server的客户端就等于获得了一个可以直接调数据库的通道。这个通道如果是无认证的那就是把数据库的钥匙挂在了门口。1.2 威胁模型谁在访问你的MCP Server我在设计认证方案之前习惯先列一遍威胁模型否则容易把精力和预算砸在错误的地方。一个典型的MCP Server部署环境里常见的访问方有三种受信任的MCP客户端你自己团队的Agent、内部使用的桌面客户端。合作方/外部开发者你开放了MCP服务给生态伙伴通过API方式调用。恶意或匿名攻击者扫描公网开放端口的人、碰巧发现你服务地址的爬虫甚至是内部有权限但不该访问特定数据的人员。前两类人需要被识别出来第三类人需要被挡在门外。如果你不设置任何认证这三类人在你眼里是没有任何区别的这在安全领域就是典型的无边界信任。1.3 一个让我印象深刻的真实案例去年我参与过一个内部数据中台项目团队把MCP Server部署在了一台公网开发机上用来给几个产品经理的Agent做数据查询演示。结果第三天运维就发现这台机器的带宽被打满了。排查下来有人把Server地址发到了一个行业群里而当时所有工具都是匿名可调的。最无语的是因为这个MCP Server还配置了shell工具当时为了演示方便加的攻击者直接通过工具调用执行了系统命令。虽然没造成太严重的后果但机器最终只能重装。这个教训告诉我们认证不是系统做完了之后才补的一节安全课它应该在服务设计之初就考虑进去。否则一个能访问核心数据的无认证MCP Server基本等于一个公开的数据库端口。2. MCP协议层给了哪些认证抓手很多人以为MCP是个新协议认证支持可能不完善所以干脆自己造轮子。实际上从MCP协议规范Spec的早期版本开始官方就定义了认证相关的机制。搞清楚这些官方抓手你才不会走弯路。2.1 标准HTTP认证Authorization头与Bearer Token目前MCP Server对外提供服务的主流传输方式是HTTP——准确说是Streamable HTTP和SSEServer-Sent Events。无论是哪种传输方式MCP的客户端到服务器的鉴权方式都遵循标准HTTP语义客户端在请求头里带上Authorization字段。具体形式一般是Authorization: Bearer token这个token可以是任意形态——一个不透明的API Key、一个JWT或者OAuth授权服务器发放的access token。MCP协议本身不关心token长什么样它只需要Server自己去解析和校验。这意味着你在认证方式上有充分的自由度但同时也意味着协议不会帮你做校验校验逻辑必须写在Server端。2.2 OAuth 2.1官方推荐的企业级方案如果你在MCP官方文档里翻认证相关内容一定会频繁看到OAuth 2.1。这是目前MCP规范里建议的完整授权框架适合需要面向多用户、多客户端的场景。OAuth 2.1本质上是从OAuth 2.0演化而来的做了一些安全上的收紧比如强制要求PKCEProof Key for Code Exchange推荐使用PARPushed Authorization Requests动态客户端注册等。在MCP上下文里这套流程大概是MCP客户端需要先向授权服务器注册自己。用户在浏览器里完成登录授权。授权服务器向客户端发放access token。客户端拿着token去请求MCP ServerMCP Server校验token后放行。这套流程的好处是用户体验好用户可以有自己的身份权限可以精细到这个用户能不能调用这个工具坏处是实现成本明显更高你得有一个授权服务器或者对接现成的IdP身份提供商。如果只是个人项目或者内部小团队我一般不建议一上来就上完整的OAuth 2.1。2.3 stdio模式下的例外本地进程间通信MCP除了HTTP传输之外还有一个非常重要的运行模式stdio。在这种模式下MCP Server作为MCP客户端启动的一个子进程存在两者通过标准输入输出通信。这种模式根本不需要认证因为安全边界在操作系统层面——谁能启动这个进程谁就能访问它。这就像你直接在本机运行一个命令行工具你不需要给命令行工具设置访问密码。所以如果你只是本地开发、本地跑Agent用stdio模式就够了完全不用考虑生产者认证。2.4 别再纠结传输方式关键是边界我在不少交流群看到有人争论SSE和Streamable HTTP哪个好、哪个更安全。说实话这两种HTTP传输方式的主要区别在于数据推送模型认证侧的差异没有想象中那么大——它们都走标准HTTP头在这个层面上是一致的。我更想强调的是认证在MCP里分为两层传输层认证HTTP请求头里带凭证和消息层认证工具调用级别的权限控制。传输层认证解决你是谁的问题消息层认证解决你能干什么的问题。很多人做完第一层就觉得安全了结果用户A拿了token之后可以调用管理员的工具——这就是没有做消息级鉴权。MCP的初始化消息和工具调用消息都支持携带访问信息你完全可以在工具调度层再做一层细粒度控制。3. 身份认证方案选型从API Key到OAuth 2.1讲完协议层下面聊聊工程上怎么落地。选型没有标准答案取决于你的调用方是谁、安全要求多高、团队有没有精力维护基础设施。我把常见的几种方案列在表格里附上我的使用建议。3.1 方案对比API Key / JWT / OAuth 2.1 / mTLS方案适用场景优点缺点推荐指数内部推荐指数对外API Key不透明Token内部工具、服务间调用、快速上线实现简单revoke简单无法包含身份信息无过期逻辑需自己维护四星两星JWT自包含Token自建多服务、需要传递用户信息自包含、可验签、适合分布式校验密钥管理是瓶颈不好主动吊销四星三星OAuth 2.1多用户、多客户端、第三方接入标准、可细粒度授权、支持动态客户端实现复杂需要授权服务器两星五星mTLS集群内部服务间调用双向证书机器身份可信度高证书分发管理麻烦不适合浏览器三星一星3.2 我的选型决策路径如果你的MCP Server只有你自己和同事的几个客户端在用部署在可信网络内我建议直接用API Key 请求头校验就够了最多再加个IP白名单。没必要把系统搞复杂。如果你需要让多个后端服务以服务身份访问MCP Server或者需要在多个Server之间传递用户上下文这时候倾向JWT方案因为token里可以带sub、scope这些字段Server端验签后直接就能拿到是谁在调用的信息不需要再查数据库。如果未来你的MCP Server要开放给第三方开发者或者企业用户那就老老实实上OAuth 2.1哪怕前期麻烦一点也值得。公开暴露的服务直接靠API Key做认证很容易出现密钥泄露后无法精细追踪的问题。3.3 scope的粒度设计不论用JWT还是OAuth我强烈建议从一开始就引入**scope权限范围**的概念哪怕初期只定义两三个scope。比如tools:read—允许读取工具列表、资源列表。tools:execute—允许调用具体工具。admin—允许管理Server配置。为什么要在第一天就做scopeMCP Server一旦上线后续工具只会越来越多到时候如果你想限制某个调用方只能使用其中一部分工具却发现所有调用方的token都是全权的那就只能改代码、重新发token非常痛苦。我在一个项目里就因为前期没做scope后来为不同客户切权限时被迫给每个客户单独部署一个Server实例运维成本直接翻倍。4. 给能跑通MCP的开发者JWT认证的可落地示例前面讲了这么多理论来点实际的。这个示例基于主流的Python MCP SDK加FastAPI实现目标很简单给MCP Server加一个JWT校验的中间件同时演示token签发和验证。代码结构你也可以直接改造成API Key方案逻辑是类似的。注意以下示例基于Python生态常见的MCP SDKFastMCP与FastAPI。不同SDK版本的API略有差异但中间件思路是通用的。4.1 签发Token认证的前提是你能给合法用户签发token。生产环境里token通常由独立的认证服务签发这里我用一段简单的函数演示import time import os import jwt # 一定要从环境变量读取不要硬编码在代码里 SECRET_KEY os.environ[MCP_JWT_SECRET] def issue_token(sub: str, ttl_seconds: int 3600) - str: payload { iss: mcp-auth, sub: sub, iat: int(time.time()), exp: int(time.time()) ttl_seconds, scope: [tools:read, tools:execute], } return jwt.encode(payload, SECRET_KEY, algorithmHS256)一个明显要注意的点是iss、sub、exp这些标准字段都要带上。实测下来很多线上问题都出在现场环境直接省略了exp结果token永久有效泄露了也没法自动失效。4.2 FastAPI中间件统一校验接下来在你的FastAPI应用里加一个全局中间件。MCP Server的子应用比如SSE或Streamable HTTP应用挂载到主应用路径下后请求会先经过这个中间件能拦下所有未认证的请求。import uuid from fastapi import FastAPI, Request from fastapi.responses import JSONResponse import jwt from mcp.server.fastmcp import FastMCP from mcp.server.streamable_http import streamable_http_app SECRET_KEY os.environ[MCP_JWT_SECRET] mcp FastMCP(demo-server) # 注册一个示例工具 mcp.tool() def get_user_email(user_id: str) - str: 根据用户ID返回邮箱示例工具 return fuser-{user_id}example.com # 创建Streamable HTTP的MCP子应用 mcp_http_app streamable_http_app(mcp) # 主FastAPI应用 app FastAPI() app.middleware(http) async def auth_middleware(request: Request, call_next): # 放过健康检查等不需要认证的路径按需调整 if request.url.path in (/health, /metrics): return await call_next(request) request_id uuid.uuid4().hex auth_header request.headers.get(Authorization, ) if not auth_header.startswith(Bearer ): return JSONResponse( status_code401, content{error: missing bearer token, request_id: request_id}, ) token auth_header.removeprefix(Bearer ) try: payload jwt.decode(token, SECRET_KEY, algorithms[HS256]) # 把用户信息放进request.state后续工具逻辑或日志中都能用到 request.state.user payload.get(sub) request.state.scope payload.get(scope, []) request.state.request_id request_id except jwt.ExpiredSignatureError: return JSONResponse( status_code401, content{error: token expired, request_id: request_id}, ) except jwt.InvalidTokenError: return JSONResponse( status_code401, content{error: invalid token, request_id: request_id}, ) return await call_next(request) # 挂载MCP子应用 app.mount(/mcp, mcp_http_app)4.3 用curl直接验证写好之后不要急着用客户端测先用curl把接口通一遍# 先签发token简化直接通过python脚本 TOKEN$(python -c import jwt; print(jwt.encode({sub:alice,iat:1743091200,exp:1743094800,scope:[tools:execute]}, your-secret, algorithmHS256))) # 不带token预期返回401 curl -i http://127.0.0.1:8000/mcp/ # 带token预期能进入MCP协议流程 curl -i -X POST http://127.0.0.1:8000/mcp/ \ -H Authorization: Bearer $TOKEN \ -H Content-Type: application/json \ -H Accept: application/json, text/event-stream \ -d {jsonrpc:2.0,id:1,method:tools/list,params:{}}我头一次写MCP认证时踩过一个很隐蔽的坑我只校验了Authorization头但MCP客户端比如Claude Desktop的MCP配置发送的是Accept头为application/json, text/event-stream的POST请求。你在做Bearer校验时中间件不要对OPTIONS预检请求直接返回401否则浏览器场景下CORS会先挂掉。我的做法是遇到OPTIONS请求直接放行让后续CORS中间件处理。4.4 工具级鉴权别停留在能访问层面中间件解决了能不能连上MCP Server的问题但工具级权限最好还要再压一层。比如有些工具需要管理员权限有些工具只有特定用户能调。你可以在工具函数内部读取request.state也可以把scope检查封装成装饰器。from functools import wraps from mcp.server.session import ServerSession # SDK依赖的会话对象 def require_scope(scope: str): def decorator(func): wraps(func) async def wrapper(*args, **kwargs): ctx: Context kwargs.get(ctx) if not ctx: return error: missing context # 注意这里通过Context拿到请求对应的会话状态 # 中间件里塞到request.state的数据也可以在这里间接取到 return await func(*args, **kwargs) return wrapper return decorator这块不同SDK的写法不太一样但思路是通的认证通过永远不等于授权通过。作为Server作者你要明确每个工具各自允许谁使用哪怕初期用硬编码白名单都行先把这个口子留出来。5. 默认密钥与硬编码密钥从CNVD-2023-17316学到的教训聊到JWT方案就绕不开密钥管理。我见过太多MCP Server示例代码里直接写着SECRET my-secret-key-please-change或者更离谱的把SDK文档里演示用的密钥原封不动搬到了生产环境。这不只是一个坏习惯在真实攻击链里它就是一个个巨大的后门。5.1 复盘Nacos默认JWT密钥绕过漏洞MCP圈子可能有人不知道但做中间件和应用服务的人对CNVD-2023-17316这个编号应该很敏感。这个漏洞出现在Nacos——一个使用非常广泛的开源配置管理中心攻击者可以利用其默认配置中的固定JWT密钥来伪造用户Token直接绕过身份认证以管理员身份调用核心API。当时这个漏洞被列为高危因为Nacos常常部署在企业内网核心位置一旦沦陷整个微服务配置都暴露了。这个漏洞的根本原因不是JWT算法被攻破了而是实现者使用了公开已知的默认密钥。你用HS256对称算法签发JWT验签和签发的密钥是同一个一旦密钥泄露任何人都能伪造任意身份的token。Nacos的默认JWT密钥是写在公开文档和开源代码里的攻击者连爆破都不用直接把这个字符串拿去签名一个管理员token就拿到了最高权限。5.2 映射到MCP Server上同样成立MCP Server生态目前还处于快速增长期很多SDK的教程代码都在GitHub上大家复制粘贴是常事。我排查过一些生产环境的MCP配置有人把JWT密钥写成一个固定的字符串直接提交到了git仓库。这意味着什么意味着所有能访问这个仓库的内部人员都能签发token一旦这个仓库被公开或被爬走攻击者就能伪装成任意用户调用你的MCP工具。不要觉得这是危言耸听。你部署的MCP Server如果接入了企业数据里面的工具等于企业的业务操作入口。攻击者不需要穷举你的接口他只要伪造一个合法token所有防护都像是在裸奔。5.3 正确的密钥管理姿势这里分享一套我自己在项目里验证过的做法适合中小规模的MCP Server部署团队生成随机高熵密钥不要自己敲键盘编密钥用系统级随机源生成。比如Python环境python -c import secrets; print(secrets.token_urlsafe(48))密钥注入环境变量写入/etc/mcp-server.env或K8s Secret中不要写进代码或配置文件。代码从os.environ[MCP_JWT_SECRET]读取。配置定期轮换JWT的好处是token有exp换密钥后旧token会验签失败但要注意平滑过渡——可以在验签时支持一个「当前密钥上一个密钥」的列表轮换期间新旧token都能验等旧token过期后再移除旧密钥。SECRETS [ os.environ[MCP_JWT_SECRET_CURRENT], os.environ.get(MCP_JWT_SECRET_PREVIOUS, ), ] def decode_token(token: str): for key in SECRETS: try: return jwt.decode(token, key, algorithms[HS256]) except jwt.InvalidTokenError: continue raise jwt.InvalidTokenError(no matching secret)优先考虑RS256/ES256如果你的MCP Server需要对接多个外部客户端用对称密钥HS256意味着你得把共享密钥发给所有调用方泄露面太大了。这时候不如生成一对公私钥用私钥签发、在Server端和客户端用公钥验签。公钥泄露了也没关系反正它只能验签不能签名。我在一个开放给第三方接入的项目里就是直接用ES256省去了很多密钥分发的麻烦。5.4 还需要注意的边界情况密钥管理之外token本身还有一些细节值得注意。比如token的audaudience字段我建议把自己的MCP Server标识写进去可以防止一个token在多个服务之间被串用。再有允许密钥失效后的用户请求要返回401而不是200很多SDK在API异常处理上做得不够到位明明token过期了还返回一个含错误信息的200状态码导致客户端误判。6. 认证之后日志记录与审计最后补一块很多人设想过但没执行好的内容MCP Server端如何记录认证与调用日志。很多人以为加了认证就万事大吉结果出问题时连谁调了什么工具都查不到。认证不是终点可观测性才是保障安全闭环的最后一环。6.1 为什么默认日志不够用MCP生态最常见的实现是Python SDK跑起来之后默认的日志输出是uvicorn的访问日志和标准库logging输出。这些日志对开发调试是够用了但对安全审计来说是远远不够的——它们缺少结构化的上下文信息哪个用户、哪种认证方式、哪个工具调用、结果是成功还是失败、请求耗时多少这些默认日志里都没有。有段时间几个MCP Server部署在ELK环境日志格式是纯文本。排查问题时像在翻一本没有目录的书效率特别低。6.2 把日志切成JSON后来我用自定义日志格式化器解决这个问题。核心思路很简单把日志输出从纯文本改造成JSON格式并允许每条日志携带额外的上下文字段。import json import logging from datetime import datetime, timezone class JsonFormatter(logging.Formatter): def format(self, record: logging.LogRecord) - str: base { timestamp: datetime.now(timezone.utc).isoformat(), level: record.levelname, logger: record.name, message: record.getMessage(), } extra getattr(record, extra_fields, None) if isinstance(extra, dict): base.update(extra) return json.dumps(base, ensure_asciiFalse) logger logging.getLogger(mcp.auth) handler logging.StreamHandler() handler.setFormatter(JsonFormatter()) logger.addHandler(handler) logger.setLevel(logging.INFO)然后在认证中间件里把关键信息塞进去。logger.info( auth_result, extra{ extra_fields: { request_id: request_id, client_ip: request.client.host, path: request.url.path, user: getattr(request.state, user, None), auth_result: success if token_valid else failed, error: error_message, } }, )实际记录里可以拆成两种认证成功日志和认证失败日志。认证失败的日志尤其值得关注因为攻击者反复尝试在短时间内制造大量401响应这些日志就是最直接的入侵信号。我在自定义日志里专门给认证失败加了一个独立的logger并在监控系统里配置了告警规则——同一个IP在5分钟内失败超过10次就触发告警。6.3 记录什么字段MCP Server的日志字段我这里给一份可以直接抄的清单字段示例为什么重要request_ida3f2c1b0-8e1d-4f2a-9b3c-1d2e3f4a5b6c贯穿请求全链路方便关联前后的日志client_ip10.0.0.8 / 203.0.113.0追溯调用来源天然用于异常检测user/subalice明确操作人身份是审计的基础scope[tools:execute]知道这个请求实际拥有什么权限path/mcp/确认访问的是哪个MCP端点toolget_user_email工具调用级日志需要额外在工具层打点auth_resultsuccess / failed认证通过与否失败要告警errortoken expired失败原因辅助定位问题duration_ms125性能排查的基础指标6.4 一套可复用的请求全链路思路最后的建议是给每个进入MCP Server的请求生成一个request_id跟着整个请求生命周期。中间件里生成放入request.state工具执行层通过Context取出来塞进工具调用的输出或者日志里。这样用户拿着一个request_id找过来的时候你一下就能定位到那条链路上的所有日志。我曾在生产上遇到过一个诡异的问题某个工具调用户反馈偶发性没有返回结果。当时就是靠request_id把认证日志、SDK内部日志、工具执行日志拼起来最后发现是数据库连接池在特定并发下被耗尽。如果没有请求链路日志这种问题只能靠猜。说回认证本身。给MCP Server做身份认证本质上和你给任何一个API接口做认证没有区别识别身份、校验凭证、控制权限、记录审计。MCP只是一个新的接入载体但它又确实比普通API更像一扇门——这扇门后面等待访问的是大模型替你操作外部世界的能力。把门锁装好别等出了事再补。如果你正在做一个对外暴露的MCP Server这是我个人的建议顺序先用中间件把Bearer Token校验做掉再补一个工具级的scope检查然后把日志切成JSON结构。这三步做完你的认证体系已经能挡住绝大多数不怀好意的访问了。至于OAuth 2.1全套流程那是当你有真实的多用户接入需求时再去投入成本也不迟。