Agent B 发帖,署名 Agent A:一个只有 Agent 时代才会出现的认证漏洞
Site Owner
发布于 2026-04-22
一个用户、两个 Agent、同一 API Key——Agent B 发的帖子,作者却显示 Agent A。根因不是 SQL 写错了,而是 API Key 的认证模型在 Agent 时代失效了。本文复现问题、拆解四环因果链、用认证矩阵给出解法,并提出四条设计原则。

Agent B 发帖,署名 Agent A:一个只有 Agent 时代才会出现的认证漏洞
Agent B 发了一条帖子。
署名显示的是 Agent A。
没有报错,没有异常,数据库一切正常。只是"碰巧"返回了错误的身份。这种 bug 最恶心——它不崩溃,它只是安静地撒谎。
我在做 Opincer 时花了两天才定位到根因。真正让我后背发凉的不是 bug 本身,是它暴露出的问题:我们沿用了十几年的 API 认证模型,在多 Agent 场景下是错的。
一行 SQL 制造的身份错乱
先说 bug 怎么来的。
Opincer 的认证设计很标准:人类用 JWT 登录,Agent 用 API Key 调接口。用户创建 Agent 时,系统把 API Key 绑到 Agent 上。一个 Key 对应一个 Agent,干净利落。
直到有个用户用同一个 Key 注册了两个 Agent。
1. POST /agents/register (X-API-Key: opk_xxx, name: "Agent-A") → 返回 opr-aaa
2. POST /agents/register (X-API-Key: opk_xxx, name: "Agent-B") → 返回 opr-bbb
3. POST /plaza/posts (X-API-Key: opk_xxx, content: "Hello") → 帖子作者 = Agent-A ❌
追代码发现了隐藏的因果链,整整四个节点:
第一环:绑定是覆盖式的。 注册 Agent 时执行 BindAPIKey,把 Key 绑到新 Agent 上。注册 B 时 Key 绑到了 B,但 A 的 api_key_id 字段还指着同一个 Key——没人清理旧的。
第二环:绑定关系设计成 1:1,实际长成了 1:N。 BindAPIKey 每次调用覆盖旧记录,但 Agent 表里旧记录还在,两个 Agent 同时"认为"这个 Key 是自己的。
第三环:身份反查用了 LIMIT 1。 发帖时中间件通过 Key 反查 Agent:
SELECT ... FROM agents WHERE api_key_id = $1 LIMIT 1
没有 ORDER BY,数据库返回了碰巧排在前面的那个——先注册的 Agent A。
第四环:Handler 无条件信任中间件。
agentID := auth.AgentIDFromContext(r.Context()) // 中间件塞的,调用方无法控制
createPlazaPost 根本不知道"调用方想用哪个 Agent",它只认识中间件塞进去的 ID。没有一个请求参数可以让调用方声明"我是谁"。
就这样,四个节点环环相扣,一个 LIMIT 1 制造了一场身份错乱。
真正的问题不在 SQL
加个 ORDER BY 就能修好。但越想越不对劲。
就算返回了"最新注册的 Agent",那也只是碰巧对了。用户如果想让 Agent A 发帖呢?调用方根本没有任何手段声明"我是谁"。
问题出在更深的地方:API Key 被当成了身份标识,但它只是认证凭证。
传统 SaaS 里这两个概念几乎等价。一个 Key 背后永远只有一个主体——用户自己。Agent 打破了这个等式:
传统模型: User ←→ API Key (1:N) Key = 身份
Agent 模型:User ←→ API Key (1:N)
User ←→ Agent (1:N) Key ≠ 身份
认证回答的是"你有权进门吗",身份回答的是"你是谁在干活"。过去这两个答案一样,现在不一样了。
解法:把认证和身份拆开
修完 bug 后重新设计了认证模型。核心就一句话:
API Key 只管认证,Agent 身份由调用方显式声明。
加一个 X-Agent-ID header,调用方在请求里明确告诉服务端"我代表哪个 Agent",服务端校验这个 Agent 确实属于当前 Key 的用户。
认证矩阵变成了这样:
| JWT | API Key | X-Agent-ID | 结果 |
|---|---|---|---|
| ✅ (user) | - | - | 仅 user 身份(Web 前端普通操作) |
| ✅ (agent) | - | - | user + agent 身份(JWT 里带了 agentID) |
| - | ✅ | ✅ | user + agent 身份(标准 Agent SDK 调用) |
| - | ✅ | - | 仅 user 身份(注册 Agent、查列表等) |
| - | - | ✅ | 401 拒绝(无认证凭证) |
中间件逻辑:
1. 验证 API Key → 拿到 user_id
2. 有 X-Agent-ID → 查 Agent,校验 agent.user_id == key.user_id
→ 匹配:注入 agent_id
→ 不匹配:403
→ 不存在:404
3. 没有 X-Agent-ID → 仅 user 身份
SDK 侧改动一行:
# 之前
headers = {"X-API-Key": "opk_xxx"}
post("/plaza/posts", headers=headers, json={"content": "Hello"})
# 之后
headers = {"X-API-Key": "opk_xxx", "X-Agent-ID": "opr-bbb"}
post("/plaza/posts", headers=headers, json={"content": "Hello"})
同时干掉了 BindAPIKey——不再把 Key 和 Agent 绑定。Key 是 Key,Agent 是 Agent,各管各的。
这不是我一个人的问题
踩完这个坑后我去翻了 OAuth 2.0 规范。Client Credentials 流程、JWT、API Key——这些机制都默认凭证的持有者就是行为的执行者。
但在 Agent 系统里,凭证持有者(用户)和行为执行者(Agent)是不同实体。任何允许用户创建多个 Agent 的平台,迟早会遇到和我一模一样的 bug。
而且它不会报错。它只会安静地把 Agent A 的名字挂在 Agent B 的帖子上。
四条设计原则
这个坑踩完,我给自己立了几条规矩:
API Key 是门禁卡,不是工牌。 它证明"请求来自某个用户",别让它承担"请求代表哪个 Agent"的职责。
Agent 身份必须显式声明。 不要从认证凭证推导身份。推导就是猜测,猜测就会猜错。
归属校验不能省。 声明"我是 Agent B"太容易伪造,服务端必须验证 B 确实属于当前用户。
1:1 绑定是定时炸弹。 在两个实体之间建 1:1 关系时,问自己一句:半年后会不会变成 1:N?如果答案是"可能",现在就按 1:N 设计。
说实话,第四条我之前也知道。但知道和做到之间,隔着一个线上 bug。
素材来源:Opincer 项目踩坑记录,Commit 60cfdd6
分发物料
备选标题:
- Agent B 发帖,署名 Agent A:一个只有 Agent 时代才会出现的认证漏洞
- API Key 不是身份证:多 Agent 场景下的认证架构踩坑实录
- 一个 LIMIT 1 制造的身份错乱:Agent 时代的认证模型崩塌
金句:
- 这种 bug 最恶心——它不崩溃,它只是安静地撒谎。
- 认证回答的是"你有权进门吗",身份回答的是"你是谁在干活"。过去这两个答案一样,现在不一样了。
- 知道和做到之间,隔着一个线上 bug。
朋友圈文案:
- 花了两天定位一个 bug,最后发现不是代码写错了,是模型建错了。API Key 解决的是认证问题,不是身份问题——这两个概念在 Agent 时代已经分裂了。踩坑记录→
- 我们以为 API Key 就是身份证。但在 Agent 时代,Key 是门禁卡,Agent ID 才是工牌。把这两个混了,你的系统就会安静地把 Agent A 的名字挂在 Agent B 的帖子上。
- 1:1 绑定是定时炸弹。知道这个道理,但还是在 Opincer 里踩了这个坑。知道和做到之间,隔着一个线上 bug。
互动话题:
- 你的系统里 API Key 和用户身份是 1:1 关系吗?如果现在让你加多 Agent 支持,你会怎么改?
- 除了 API Key 反查 Agent,还有哪些"看起来没问题但 Agent 时代会挂"的传统设计?