Skip to main content

06 - 凭证与密钥:让 key 根本不进上下文

前置01 - 威胁模型里的外传(exfiltration),04 - 防线在哪一层里的六道关卡。

本篇回答:Agent 要拿着一堆密钥去调外部服务,这些密钥会从哪里漏出去,以及该在哪一层堵。

会用到的词

  • 凭证代持(credential brokering):模型手里只有一个不可直接使用的句柄,真正的密钥由执行层在出站的那一刻注入
  • token 透传(token passthrough):服务端把客户端递来的令牌原样转发给下游,自己不验证这枚令牌是不是发给自己的
  • canary token:一枚故意放进系统、正常业务永远不会用到的假凭证。它一旦被使用,就说明有人读到了它不该读到的地方
  • 爆炸半径(blast radius):一枚凭证泄漏之后,攻击者实际能造成多大范围的损失

一、密钥会在哪些位置留下副本

传统服务里讨论密钥安全,讨论的是「别把 key 提交进 git」「别写死在配置里」。Agent 把问题换了一个形状:密钥现在会流经一个专门设计来记录一切、并且要把一切喂给第三方模型的管道。

先把这条管道摊开,看一把 key 在路上被复制了几次。

一把 API key 从配置流到出网,沿途会在哪里留下副本这一段里,每一处都是一份完整副本密钥存储Vault · KMS只有取用方能读执行进程内存里的变量用完即弃模型上下文写进提示词后每轮都重发一遍tool_calls模型自己生成的工具参数日志与 TraceAPM · 异常堆栈回放用的原始报文出站请求Header 还是 URL决定对方记不记不留副本不留副本模型厂商侧留存可观测性全量落盘保留期通常按月算URL 会进对方日志前两格是你完全控制的范围,从第三格开始每一处都在复制这把 key,而且复制出去的副本你删不掉。所以问题不是「怎么保护上下文里的 key」,而是「怎么让 key 根本不出现在第三格」—— 边界划在第二格和第三格之间。
模型厂商侧的留存、可观测性平台的落盘、上游服务的访问日志,这三处都不在你的删除权限范围内。密钥一旦越过第二格,撤销就只剩「轮换」这一条路。

这张图给出了本篇的全部结论方向:密钥安全在 Agent 里不是加密问题,是数据流问题。 加密只解决存储和传输,解决不了「它被复制到了四个我管不着的地方」。

1.1 为什么密钥这么容易进上下文

不是因为工程师粗心,是因为三条路都很自然:

第一条,把凭证写进系统提示词。 一个查询 GitHub 的 Agent,最省事的写法是在系统提示词里放一句「调用 GitHub API 时使用 token ghp_xxx」,然后给模型一个通用 HTTP 工具。这样不用写任何工具封装代码,模型自己会拼请求。代价是这把 token 从此在每一次请求里都会被发给模型厂商。

第二条,密钥是工具的一个参数。 MCP 工具的参数 schema 里带一个 api_key 字段,看上去和 repobranch 没什么区别。但 repo 是模型该决定的,api_key 不是 —— 模型不该有权决定用哪把钥匙。

第三条,报错的时候漏出来。 一个 401 异常的堆栈里往往带着完整的请求头。这个堆栈会被 except 抓住,格式化成字符串,塞回模型上下文让它「自己看错误重试」。这是最隐蔽的一条 —— 正常路径上密钥没进上下文,异常路径上进了。

1.2 三种写法的对照

# ❌ 写法一:凭证进系统提示词
# 后果:每一轮对话都把这把 token 发一遍给模型厂商。
# 对话历史留存多久,这把 token 就暴露多久;轮换之后旧对话里的仍然有效。
SYSTEM_PROMPT = f"你是运维助手。调用 GitHub API 时用这个 token: {GITHUB_TOKEN}"

# ❌ 写法二:凭证是工具参数
# 后果:模型「决定」用哪把钥匙 —— 这意味着提示注入可以让它换一把钥匙,
# 也意味着这把钥匙会出现在 tool_calls 里,被可观测性链路完整记录。
{
"name": "github_request",
"parameters": {
"path": {"type": "string"},
"api_key": {"type": "string"}, # 这一行是全部问题的来源
},
}

# ❌ 写法三:异常回灌
# 后果:正常路径上密钥没进上下文,异常路径上进了。
# requests 的异常字符串里会带上完整 URL,URL 里如果有 ?key=... 就一起进去了。
try:
resp = requests.get(url, headers={"Authorization": f"Bearer {token}"})
resp.raise_for_status()
except Exception as e:
messages.append({"role": "tool", "content": f"请求失败: {e!r}"}) # e 里有什么你不知道
# ✅ 正确形态:模型只见句柄,凭证由执行层在出站那一刻注入

# 1) 工具 schema 里没有任何凭证字段。模型能决定「调哪个连接」,
# 但「那个连接对应哪把钥匙」由服务端查表,模型无从干预。
TOOL_SCHEMA = {
"name": "github_request",
"parameters": {
"path": {"type": "string"},
"connection": {"type": "string", "enum": ["github_main", "github_readonly"]},
# ↑ enum 是关键:模型只能在预先登记的连接里选,
# 注入进来的 "connection": "../../prod_db" 会在 schema 校验阶段就被拒
},
}

# 2) 真正的凭证在执行层解析,全程不经过任何会被记录的结构
def execute(call, ctx):
# 按「当前用户 + 声明的连接」查凭证,而不是按模型给的字符串查。
# 这一步同时完成了两件事:取到 token,以及确认这个用户有权用这个连接。
token = broker.resolve(user_id=ctx.user_id, connection=call["connection"])
if token is None:
return {"error": "connection not available"} # 不回显为什么不可用,避免探测

resp = requests.get(
f"https://api.github.com{call['path']}",
headers={"Authorization": f"Bearer {token}"}, # 只在这一行存在
timeout=10,
)
# 3) 回灌给模型的是结构化的、自己构造的结果,不是异常对象的字符串化
if resp.status_code >= 400:
return {"error": f"upstream returned {resp.status_code}"}
return {"status": resp.status_code, "body": resp.text[:8000]}

三处改动的分工要说清楚:enum 挡的是「模型被诱导去访问一个没登记的连接」;broker.resolveuser_id 挡的是「A 用户的会话用到了 B 用户的凭证」;自己构造错误消息挡的是异常回灌。少任何一处,另外两处都还是漏的。

二、凭证代持长什么样

上面 broker.resolve 那一行藏了整套机制。展开看。

直传:同一把 key 出现在两处会被长期保存的地方模型上下文系统提示词里写着ghp_xxxxxxxxtool_calls 参数模型把它抄进了api_key 字段上游 API正常返回轮换是唯一的撤销�手段而两处副本你都删不掉注入还能让模型换钥匙代持:模型只见句柄,真凭证在出站那一刻才存在模型上下文只有一个句柄connection=github_main代持层查表键是「用户 + 句柄」不是模型给的字符串出站注入Authorization 头进程内存里只活一瞬上下文里没有可用凭证注入拿到句柄也没用代价:多一次查表 RTT句柄本身不是秘密 —— 它离开这个用户的会话就解析不出任何东西,所以它进日志、进上下文都无所谓。
代持的核心不在「加了一层」,而在于查表的键里带了「当前用户」。少了这一维,代持退化成一个换了名字的全局密钥表,A 用户依然能用到 B 用户的连接。

2.1 凭证从哪来

代持层自己也不该存明文。LiteLLM 的做法有参考价值 —— 它把「凭证从哪读」抽成了一层可替换的后端,litellm/secret_managers/ 下目前有这些实现:

后端文件适用场景
HashiCorp Vaulthashicorp_secret_manager.py自建,动态凭证与租约续期
AWS Secrets Manageraws_secret_manager_v2.py已在 AWS 上,走 IAM 免密
Google Secret Managergoogle_secret_manager.pyGCP 同上
Google KMSgoogle_kms.py只做加解密,密文自己存
CyberArkcyberark_secret_manager.py企业已有特权账号管理体系
自定义custom_secret_manager.py接内部的凭证系统

抽象成可替换后端这件事本身比选哪一个更重要:凭证读取如果散落在各处的 os.environ,你就没有任何一个地方能加上审计和轮换。

2.2 代持和「用户授权」是两回事

代持解决的是「凭证不进上下文」。它不解决「这个用户有没有权限做这件事」。

一个常见误解是把代持当成授权:既然凭证按用户查表,那查得到就说明有权限。这在只有一把服务账号 key 的时候是错的 —— 所有用户查到的都是同一把 key,代持层只是没让 key 露出来,权限边界完全没有建立。

真正的用户级授权是 02 篇讲的 OAuth 流程:每个用户自己去上游授权,代持层存的是这个用户的令牌。两者的关系是:代持是承载方式,OAuth 是权限来源。只做代持不做 OAuth,你得到的是一个不泄漏的越权系统。

三、规范为什么禁止 token 透传

MCP 官方规范里有一条措辞非常硬的规定。docs/docs/2026-07-28/tutorials/security/security_best_practices.mdx 的 Token Passthrough 一节:

MCP servers MUST NOT accept any tokens that were not explicitly issued for the MCP server.

「不接受不是发给自己的令牌」听上去是句废话 —— 但它禁止的是一种非常自然的实现。

3.1 被禁止的那种实现

设想你在写一个包装 GitHub 的 MCP Server。最省事的做法是:让客户端自己去 GitHub 拿 token,把 token 放在请求头里发给你,你原样转发给 GitHub API。你什么都不用管,不用做 OAuth 流程,不用存凭证。

规范说这个不行。理由在同一份文档里列了四条,值得逐条拆,因为每一条对应一个具体的失效场景:

规范给的理由具体失效场景
绕过安全控制你在 MCP Server 上做的限流、参数校验、工具级权限,全部建立在「请求经过我」的假设上。客户端拿着同一枚 token 可以直接打上游,你的控制点被绕过
审计链断裂上游日志里看到的是 token 主体的身份,不是你的 MCP Server。出事之后没法回答「哪个客户端发起的」
信任边界被打穿上游对「来自这个 Server 的请求」有一套信任假设。一枚在多个服务间通用的 token,攻破其中一个就等于攻破全部
未来兼容性今天是纯代理,明天要加安全控制的时候会发现加不上 —— 因为你从来没有过一个属于自己的身份

第二条对 Agent 尤其致命。Agent 出事之后的第一个问题永远是「它到底做了什么」,而透传实现下上游日志里只有一个模糊的主体,Agent 可观测性那套链路追踪在跨出你的系统边界之后就断了。

3.2 正确做法是两段令牌

规范要求的形态是:客户端拿一枚发给 MCP Server 的令牌来访问你;你验证它的 aud(audience)声明确实是自己;然后你用自己的凭证去访问上游。

两段令牌各自独立,中间由你的身份和授权决策连接。同一份规范的 Security Considerations 里给了配套要求:

MCP clients MUST include the resource parameter in authorization and token requests

MCP servers MUST validate that tokens presented to them were specifically issued for their use

resource 参数来自 RFC 8707,作用是在申请令牌的时候就声明「这枚令牌是要拿去访问哪个资源的」,授权服务器据此把 aud 填对。没有这一步,aud 校验就无从谈起 —— 因为所有令牌的 aud 都是空的或者一样的。

四、日志侧脱敏:拆一份真实的正则表

前面三节都在讲「别让密钥进去」。但工程现实是总会漏 —— 用户直接在对话里粘了一把 key,某个第三方 SDK 的异常带出了请求头。所以还需要一道出口侧的兜底。

LiteLLM 把这件事收敛在了一个文件里,litellm/litellm_core_utils/secret_redaction.py。它的自述说明了为什么要单独成模块:

This module owns the compiled regex and the public redact_string helper so that any part of the codebase (logging, exception mapping, etc.) can scrub secrets from strings without depending on the logging-configuration module.

「不依赖日志配置模块」这句是重点 —— 脱敏如果实现在日志组件里,那么异常映射、错误回显、指标上报这些不走日志组件的路径就全是漏的。

4.1 正则表分成三类

把它的模式列表按思路分组,能看出三种完全不同的检测策略:

# ── 第一类:按「值的形状」匹配 ──
# 适用于有固定前缀和长度的凭证。误报率极低,因为这些前缀是各家厂商注册占用的。
r"(?:AKIA|ASIA)[0-9A-Z]{16}", # AWS access key ID:前缀 + 定长 16 位大写字母数字
r"AIza[0-9A-Za-z\-_]{35}", # Google API key:AIza 前缀 + 定长 35
r"dapi[0-9a-f]{32}", # Databricks PAT
r"\bya29\.[A-Za-z0-9_.~+/-]+", # GCP OAuth2 access token
r"\beyJ[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]+\.[A-Za-z0-9_-]*", # 裸 JWT:eyJ 是 {" 的 base64

# ── 第二类:按「键的名字」匹配,不管值长什么样 ──
# 这一类是给「值没有可识别形状」的凭证兜底的:数据库密码、内部签名密钥,
# 它们可能就是一串普通字符,第一类的形状匹配完全抓不到。
r"(?:master_key|xai_key|database_url|db_url|connection_string|"
r"aws_secret_access_key|aws_session_token|aws_access_key_id|"
r"signing_key|encryption_key|"
r"auth_token|access_token|refresh_token|"
r"slack_webhook_url|webhook_url|"
r"database_connection_string|"
r"huggingface_token|jwt_secret)"
r"""['\"]?\s*[:=]\s*['\"]?[^\s,'\"})\]{}>]+""",

# ── 第三类:按「结构」匹配 ──
# PEM 块和 service account JSON 都是多行结构,靠起止标记定位。
r"-----BEGIN[A-Z \-]*PRIVATE KEY-----[\s\S]*?-----END[A-Z \-]*PRIVATE KEY-----",
r'\{[^{}]*"type"\s*:\s*"service_account"[^{}]*(?:\{[^{}]*\}[^{}]*)*\}',
r"(?<=://)[^\s'\"]*:[^\s'\"@]+(?=@)", # 连接串里的 user:pass@host 段

三类的取舍很清楚:第一类精准但只覆盖已知厂商;第二类覆盖面广但依赖「作者按惯例给变量命名」;第三类最可靠但只适用于有明确边界的结构。三类叠加之后仍然漏的,是「值没有形状、键名不在表里」的那一类——比如某个内部系统的 x_internal_ticket

三类策略各自覆盖一块,叠加之后仍然有一块盖不住① 按值的形状AKIA + 16 位大写数字AIza + 35 位eyJ 开头的裸 JWT② 按键的名字master_keydatabase_url凡是叫 _secret 的③ 按结构PEM 私钥块service_account JSON连接串里的 user:pass@叠加后仍然漏的值没有形状键名又不在表里例:x_internal_ticket准,几乎不误伤只覆盖注册过前缀的能抓值没形状的依赖作者按惯例命名最可靠,起止明确只适用这几种结构得自己往表里加加不全是常态三类都是「事后擦干净」,没有一类能阻止密钥进上下文 —— 那件事只能靠第一、二节的代持。所以脱敏的验收标准不是「覆盖率多少」,而是「漏掉的那一类,有没有别的机制在管」。
右边那一格是所有脱敏方案的共同盲区,也是它只能当兜底、不能当第一道防线的原因。内部签发的凭证几乎必然落在这一格里。

4.2 一处值得注意的性能防御

# 这条正则匹配 password / secret 一类的键名。
# 注释里那句「Word boundary prevents O(n^2) backtracking」不是随手写的:
# \w* 后面接一个可选匹配的组,在遇到一长串连续单词字符(比如一段 base64)
# 而最终匹配失败时,正则引擎会在每个起始位置重试一遍,退化成平方复杂度。
# 前面的 (?:^|(?<=\W)) 把起始位置钉死在「非单词字符之后」,重试次数从 O(n) 降到 O(边界数)。
r"(?:^|(?<=\W))\w*(?:password|passwd|client_secret|secret_key|_secret)"
r"['\"]?\s*[:=]\s*['\"]?[^\s,'\"})\]{}>]+",

这一处提醒了脱敏方案的一个真实代价:它跑在每一条日志、每一次异常映射上。 一个写得不好的脱敏正则在遇到一段长 base64 附件时会让整个请求线程挂住 —— 这就变成了一个由普通用户输入触发的拒绝服务。上生产之前,拿一段 100KB 的随机 base64 跑一遍你的脱敏函数,看耗时。

4.3 前缀指纹能覆盖到什么程度

LiteLLM 企业版还有一套更细的密钥指纹,enterprise/litellm_enterprise/enterprise_callbacks/secrets_plugins/ 下有 94 个文件,一个厂商一个:airtable_api_key.pydatabricks_api_token.pydoppler_api_token.pydynatrace_api_token.pyatlassian_api_token.py……

这个数字本身说明了两件事:

  • 前缀指纹是可枚举的,也就是有限的。 94 个覆盖了主流 SaaS,但你公司内部签发的凭证一定不在里面,得自己加
  • 靠这套东西做「防泄漏」是靠不住的。 它的定位是兜底,是「万一漏了至少日志里是干净的」,不是第一道防线

真正的第一道防线是第一节的结论:让 key 根本不进上下文。脱敏是最后一道,不是唯一一道。

五、canary token:唯一不会误报的检测

前面所有手段都是「防」。有一个手段是「测」——而且是所有检测手段里唯一一个理论上零误报的。

四个位置各埋一枚,哪一枚响了就知道漏在哪一层埋点 A:密钥表代持层里的一条假记录埋点 B:系统提示词一把「废弃备用 key」埋点 C:知识库文档运维手册里的示例值这枚 key 被使用了正常业务永远不会用它告警:确定泄漏不是「可能」不是「疑似」是已经发生附带来源 IP 与 UAA 响 → 代持层被读穿B 响 → 上下文被回显C 响 → 检索被诱导外传响的顺序还能还原路径代价:要维护一套「看起来真、但打过去一定失败并记录」的假凭证服务。
其他检测手段都在回答「这看起来像不像泄漏」,canary 回答的是「泄漏已经发生了,而且是从这一层」。它抓不到没被使用的泄漏,所以是补充不是替代。

5.1 为什么它零误报

所有基于内容特征的检测 —— 正则、分类器、熵值 —— 都在做一件事:判断某段文本像不像凭证。像就有误报,不像就有漏报,没有第三种可能。

canary 换了一个判据:它不看内容,它看使用。 一枚正常业务链路上永远不会被调用的凭证,一旦有人拿它去认证,就只有一种解释 —— 有人读到了它所在的位置。这里没有「像不像」的空间。

代价是它只能发现已经被利用的泄漏。攻击者把密钥库整个 dump 走但还没开始用,canary 是哑的。所以它的定位是「事故检测」,不是「泄漏预防」。

5.2 埋点位置的选择

关键在于让每一枚 canary 唯一对应一个位置,而不是随便撒几枚。上图里 A/B/C 三枚各自能证明一件不同的事:

  • A(代持层的假记录)响了 → 有人读到了代持层的存储,这是最严重的一级
  • B(系统提示词里的「废弃备用 key」)响了 → 系统提示词被回显出去了,对应 01 篇的提示词泄漏
  • C(知识库文档里的示例值)响了 → RAG 检索的内容被诱导外传,对应下一篇 07 - 记忆与上下文外泄

每多埋一个位置,你多一条可以被单独证伪的假设。 这比「在系统里放一枚 canary」有用得多。

六、爆炸半径:假设它一定会漏

前五节都是在降低泄漏概率。这一节假设概率降不到零 —— 因为在 Agent 里它确实降不到零 —— 然后问:漏了之后损失有多大。

四个变量各自压缩一个维度:

变量不做的后果做了之后
有效期一把长期 token 泄漏,攻击窗口是「到你发现为止」,可能是几个月15 分钟的令牌,攻击者拿到手时可能已经过期
主体粒度一把服务账号 key,泄漏就是全量数据按用户签发,泄漏只影响这一个用户的数据
scopetoken 能做的事等于这个账号能做的事只读的 Agent 拿只读 scope,写操作根本不在这枚令牌的能力范围内
轮换「轮换」是一次需要停机协调的事故响应常态化自动轮换,事故响应退化成「提前触发一次例行操作」
四个变量各压缩一个维度,效果是相乘而不是相加有效期压缩「时间」维度长期 token:几个月15 分钟:可能已过期主体粒度压缩「范围」维度服务账号:全量数据按用户签发:一个人scope压缩「能力」维度全权限:能删能改只读:写操作不存在轮换压缩「响应」成本不做:一次停机事故常态化:提前触发一次规范里写的��是 SHOULD需要真正的用户级授权最容易做也最容易跳过前三条做好了才轮得到scope 被跳过的原因很实际:申请一把全权限 key 只要一次审批,拆成五把只读 key 要五次。对策是把它绑进流程 —— 新增工具时把所需 scope 写进工具定义,跟代码一起过 review,而不是事后补。这四条都不降低泄漏概率,它们只决定泄漏之后你赔多少。
这一节和前五节的关系是正交的:前五节让泄漏更难发生,这一节假设它已经发生了。两边都要做,因为在 Agent 里前者的概率降不到零。

MCP 规范在 Token Theft 一节明确要求了前两条里的一条:

Authorization servers SHOULD issue short-lived access tokens to reduce the impact of leaked tokens.

这四个变量里,scope 是最容易做又最容易被跳过的。原因很实际:申请一把全权限的 key 只要一次审批,按功能拆成五把只读 key 要五次。工程上的对策是把它绑进流程 —— 新增一个 Agent 工具时,把「这个工具需要哪些 scope」写进工具定义本身,让它和代码一起过 review,而不是事后补。

七、检查清单

按第一节那张图的位置顺序自查,每一条都对应上面某一节:

让 key 不进上下文(第一、二节)

  • 系统提示词里搜一遍 keytokensecretpassword,确认没有真值
  • 每个工具的参数 schema 过一遍,有凭证字段的删掉,改成 enum 约束的连接句柄
  • 凭证查表的键里包含当前用户身份,不是只有句柄
  • 异常不直接字符串化回灌给模型,改成自己构造的结构化错误
  • 凭证读取统一走一层可替换的后端,不散落在 os.environ

授权边界(第三节)

  • MCP Server 校验入站令牌的 aud 确实是自己,不透传
  • 客户端申请令牌时带 resource 参数,否则 aud 校验没有依据
  • 确认「代持」之外还有真正的用户级授权,不是所有用户共用一把服务账号 key

兜底与检测(第四、五节)

  • 脱敏实现在独立模块,日志、异常映射、指标上报都调它
  • 内部签发的凭证格式加进脱敏正则表,不指望开源那 94 个覆盖你
  • 拿 100KB 随机 base64 跑一遍脱敏函数,确认没有回溯爆炸
  • 至少在代持层、系统提示词、知识库三处各埋一枚 canary

爆炸半径(第六节)

  • 令牌有效期按分钟算,不是按月
  • 按用户签发,不是一把服务账号 key 走天下
  • scope 写进工具定义,跟代码一起 review
  • 轮换是自动化的例行操作,不是需要协调的事故响应

下一篇 07 - 记忆与上下文外泄讲的是另一半:凭证之外,Agent 记住的关于用户的一切,怎么被诱导吐出来,以及怎么被写入不属于用户的内容。

← 回到 专题索引