2026年9月,VentureBeat 报道称 AI 代理利用泄露的凭证入侵了 395 家组织。报告指出一个简单事实:身份系统仍然把代理的凭证当作人类的密码来对待。没有人会期望人类每隔几分钟就输入一次密码,因此也没有人把代理凭证设置为如此快速过期。攻击者正是看中了这一漏洞并加以利用。Hugging Face 的另一次泄露事件也源于同一问题:一个代理持有的凭证超出了其被分配任务的有效期。
大多数 AI 代理在启动时会获取一个机器对机器(M2M)访问令牌,并在进程整个生命周期内一直使用它。如果该令牌被写入日志文件、支持工单或被复制的环境变量中,它将在其签发方配置的有效期内一直保持有效。对于大多数身份提供商来说,这个有效期通常在一小时到一天之间。
为了尝试解决这个问题,我在 Kinde 这个身份提供商上构建了同一个代理的两个版本。Kinde 会颁发 M2M 访问令牌并对每次调用进行验证。它还允许你为每个应用设置访问令牌在过期前的有效时长。在我的构建中,一个代理采用 Kinde 的默认设置,之后不再检查;另一个代理则将其令牌视为保质期短的物品,在保质期耗尽前重新获取令牌。
接下来,我将尝试窃取这两个令牌并再次重复使用它们。
泄露的令牌为何仍然有效
Kinde 中的 M2M 凭证由两部分组成:客户端 ID 和客户端密钥,以及当应用呈现该 ID 和密钥时 Kinde 颁发的访问令牌。访问令牌是应用在每次 API 调用时实际发送的内容。Kinde 对其进行签名,任何信任 Kinde 的服务器都可以在不再调用 Kinde 的情况下验证该签名。
客户端 ID 和密钥很少会更改。大多数团队会在环境变量中一次性设置它们,然后保持不变。访问令牌则应当有所不同:Kinde 为其设定一个有效期,超过该期限后,无论谁持有它,它都将失效。
问题在于代理在获得 Kinde 颁发的访问令牌后,其代码会如何处理该令牌。静态代理在启动时获取一个令牌,将其存储在内存中,并在进程重启之前一直复用它用于每次调用。如果该进程运行数小时或数天,令牌也会随之保持有效。在此期间如果有人复制了该令牌,他们手中得到的仍然是有效的凭证。
轮换代理正好相反。它在每次调用前检查令牌的年龄。在令牌即将过期之前,代理会丢弃它并向 Kinde 请求一个新的令牌。该令牌的任何副本,在其短暂生命周期的任意时点生成,几分钟内就会失效。
两种代理获取令牌的方式相同:客户端凭据授权。代理直接将其客户端 ID 和客户端密钥发送到 Kinde 的令牌端点。此过程中没有人工干预,也没有浏览器参与。Kinde 返回一个已签名的访问令牌。这是服务间调用的标准 OAuth 流程,无论返回的令牌有效期是两分钟还是两天,流程都是相同的。授权本身并不决定令牌的有效时长,这一点由 Kinde 中的应用设置 决定。
强制执行的接合
在此构建中,两个代理调用相同的 API,而该 API 对每个请求都执行相同的检查,并且没有根据调用方的代理进行分支。
sequenceDiagram
participant Agent as Agent (static or rotating)
participant Kinde
participant API as Records API (Convex)
Agent->>Kinde: client_credentials grant
Kinde-->>Agent: access_token (24h or 120s expiry)
Agent->>API: GET /api/records?action=... (Bearer token)
API->>Kinde: verify signature via JWKS
API-->>Agent: 200 + data, or 401 if expired/invalid
Note over Agent,API: Later, an attacker replays a captured token directly
Agent->>API: same token, no agent involved
API-->>Agent: static: 200 (still valid) / rotating: 401 (expired)该 API 是一个单一的 Convex 函数。它从请求中读取 bearer 令牌,验证其签名是否与 Kinde 的公钥集(JWKS)匹配,并确认令牌未过期:
const { payload } = await jwtVerify(token, getJwks(), {
issuer: requiredEnv("KINDE_ISSUER"),
audience: requiredEnv("KINDE_M2M_AUDIENCE"),
});
const mode = modeForClientId(payload.azp as string | undefined);
if (!mode) return await deny(403, "token not issued to a known agent client");
jwtVerify 调用(来自 jose 库)在令牌签名错误或其 exp 声明过期时立即抛出异常。这一检查正是将 Kinde 配置的过期时间转化为实际拒绝的关键。该函数本身不会知道或关心请求是由哪个代理发送的;它对所有令牌采用相同的处理方式,而令牌自身的过期时间决定了结果。
两个代理之间的唯一区别在于 Kinde 的控制面板,而不在此代码中。静态代理的 Kinde 应用保持默认的访问令牌过期时间:86,400 秒,即一天。旋转代理的应用设置为 120 秒。只需修改这一个数字,上面相同的验证代码就会开始按照不同的计划拒绝令牌,而无需自行重新部署。
两个凭据管理器,一个验证函数
静态代理的凭据管理器只获取一次令牌并保持它:
let staticToken: CachedToken | null = null;
export async function getStaticCredential(): Promise<CachedToken> {
if (staticToken) return staticToken;
staticToken = await fetchM2MToken(
env("STATIC_AGENT_CLIENT_ID"),
env("STATIC_AGENT_CLIENT_SECRET"),
);
return staticToken;
}
轮换代理的凭据管理器在每次使用前会检查令牌的年龄,并预留15秒的安全余量;如果旧令牌即将过期,则获取新令牌:
const ROTATION_SAFETY_MARGIN_MS = 15_000;
let rotatingToken: CachedToken | null = null;
export async function getRotatingCredential(): Promise<CachedToken> {
const now = Date.now();
if (rotatingToken && now < rotatingToken.expiresAt - ROTATION_SAFETY_MARGIN_MS) {
return rotatingToken;
}
rotatingToken = await fetchM2MToken(
env("ROTATING_AGENT_CLIENT_ID"),
env("ROTATING_AGENT_CLIENT_SECRET"),
);
return rotatingToken;
}
两个函数互不了解对方,也不清楚另一端的 API 会对令牌做什么。影响结果的唯一因素是 Kinde 为每个令牌设定的有效时长,以及调用代码是否遵守该时长。
构建中的第三个文件是一个封闭的操作注册表,列出了两个代理都可以调用的仅有的三个操作:list_records、read_record 和 export_records。API 在甚至查看令牌之前就会拒绝任何其他操作。这使得演示的范围保持狭窄:这里的测试关注的是令牌生命周期,而不是代理应该被允许执行哪些操作。
我在加固过程中发现的错误
在审查 API 的验证代码时,发现了一个错误。当请求携带无令牌,或携带来自 API 不认识的应用的令牌时,日志代码会将该请求的日志条目默认为 mode: "static"。匿名或格式错误的请求随后会在仪表盘中显示为好像是静态代理发出的。
这并未影响下面的证明数字,因为此构建中的每次调用都来自两个已知代理之一。不过,在证明运行之前已经修复了该问题:未归属的请求现在会记录为 mode: "unknown",这是一个与 "static" 和 "rotating" 互不相同的独立值。
实时证明
配置好两个 Kinde 应用程序(静态代理 24 小时,旋转代理 120 秒)后,证明分为四步进行。首先,每个代理向 Kinde 请求一个令牌。其次,在同一时刻捕获两个令牌,就像日志抓取工具或支持票据截图所做的那样。第三,脚本等待超过旋转代理的 120 秒窗口。第四,捕获到的两个令牌将被原样重放,直接发送到 API,而不经过任何代理自身的代码。
静态代理的应用,默认有效期为 86,400 秒:
旋转代理的应用,设置为 120 秒:
使用 Convex 构建的实时仪表盘会在每次调用发生时实时显示两个代理的历史记录:
回放产生了以下结果:
| 代理 | 令牌有效期 | 重放结果 | 状态 | 延迟 |
|---|---|---|---|---|
| 静态 | 24h(默认) | 仍然有效 | 200 | 1353ms |
| 轮换 | 120s | 失效 | 401 | 938ms |
被盗令牌可用时间:一天 对比 两分钟
静态代理泄露令牌在被捕获后超过两分钟仍能成功认证,使用与轮换代理令牌失败时完全相同的重放。它将在其 24 小时生命周期内一直保持认证,因为此构建以及大多数生产环境均未检查令牌是否被复制。Kinde 的 JWT 验证仅检查令牌签名是否正确且未过期。它无法知道面前的令牌是否为副本。
轮换代理泄露令牌在 120 秒后失效,这正是其 Kinde 应用所设的限制。获取该令牌的人最多只有两分钟的使用时间,之后 Kinde 的签名检查就会在每次尝试时返回 401。
一天 与 两分钟之间的差距正是短期凭证的全部价值。攻击方式不变,API 验证代码也不变。只有对‘此令牌副本仍然有价值多久’这个问题的答案才会改变,而答案来源于 Kinde 仪表盘上一个屏幕所设的单一数字。
我对此的看法
Kinde 已为每个 M2M 应用程序颁发带过期时间的令牌,而大多数团队从未修改该设置。我认为这不是 Kinde 的问题。大多数 agent 代码也不会检查过期时间;它们只是保持 Kinde 在启动时返回的令牌。短期凭证并不是必须添加的功能,原始机制已经存在。缺少的是能够将过期时间视为真实有效的代码,而不是只在一次生成令牌后就假设它永远有效的代码。
此次修复仅需两项更改:在 Kinde 仪表盘中设置一个数字,以及编写一个在检查缓存之前先检查时钟的凭证管理器。这两项更改均未触及 API 的验证逻辑。
我认为更难养成的习惯不是编写代码,而是要记住:代理人在内存中持有的凭证,实际上是可能离开内存的凭证。Hacker News 上的开发者曾询问,是否真的可以信任一个持有 API key 的代理人。大多数人得出了相同的答案:为代理人发放一个临时且权限受限的凭证,该凭证在请求时会被记录下来。而不是让一个长期有效的密钥一直驻留在环境变量中。来自已经记录每次颁发的身份提供者的短期访问令牌,是一种直接获取这种凭证的方式,无需额外构建单独的凭证代理。
局限性
Kinde 文档指出,将 M2M 应用的客户端密钥轮换作为一种手动操作,通过仪表盘或管理 API 触发,而不是 Kinde 按计划自动执行的操作。此构建会轮换访问令牌,而 Kinde 在每次客户端凭据请求时会颁发全新的令牌。这一层是防止令牌泄漏场景的关键,但它与密钥轮换是不同的机制,两者不应混淆。
120 秒是为了让此证明能在几分钟内运行而选择的数字,并非生产环境的建议。在生产环境中,窗口通常设定在 5 到 15 分钟之间,这是在工作负载能够容忍刷新调用的频率之间进行的权衡。
API 的 JWT 验证在所有失败情况下都返回通用的 401 错误。它不会告知调用者是令牌已过期,还是令牌来自 API 不认识的应用程序。对于此演示来说这已经足够。真正的审计追踪需要能够区分这些情况。
代码和来源
完整源码: github.com/sholajegede/rotating-credentials-demo
本文中的所有数字均来自对真实 Kinde 租户和真实 Convex 部署的实时运行。克隆仓库,在您自己的租户上运行相同的证明,并在评论中填写您的数字。如果您的静态代理泄露的令牌存活时间超过 24 小时,或者您的轮换代理的令牌在 120 秒内失效,请告诉我原因。


