☰
MCP Server身份认证实战:从API Key到OAuth 2.1防止裸奔
2026/9/26 8:49:18 网站建设 项目流程

最近有个朋友找我排查线上问题,他搭的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和SSE(Server-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演化而来的,做了一些安全上的收紧,比如强制要求PKCE(Proof Key for Code Exchange),推荐使用PAR(Pushed Authorization Requests),动态客户端注册等。在MCP上下文里,这套流程大概是:

  • MCP客户端需要先向授权服务器注册自己。
  • 用户在浏览器里完成登录授权。
  • 授权服务器向客户端发放access token。
  • 客户端拿着token去请求MCP Server,MCP 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配置。

为什么要在第一天就做scope?MCP Server一旦上线,后续工具只会越来越多,到时候如果你想限制某个调用方只能使用其中一部分工具,却发现所有调用方的token都是全权的,那就只能改代码、重新发token,非常痛苦。我在一个项目里就因为前期没做scope,后来为不同客户切权限时被迫给每个客户单独部署一个Server实例,运维成本直接翻倍。


4. 给能跑通MCP的开发者:JWT认证的可落地示例

前面讲了这么多理论,来点实际的。这个示例基于主流的Python MCP SDK加FastAPI实现,目标很简单:给MCP Server加一个JWT校验的中间件,同时演示token签发和验证。代码结构你也可以直接改造成API Key方案,逻辑是类似的。

注意:以下示例基于Python生态常见的MCP SDK(FastMCP)与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, algorithm="HS256")

一个明显要注意的点是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 f"user-{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_code=401, 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_code=401, content={"error": "token expired", "request_id": request_id}, ) except jwt.InvalidTokenError: return JSONResponse( status_code=401, 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', algorithm='HS256'))") # 不带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的aud(audience)字段,我建议把自己的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_ascii=False) 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全套流程,那是当你有真实的多用户接入需求时,再去投入成本也不迟。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询