API Key 轮换实战:密钥泄漏怎么办,五分钟换掉一把 key
先说一个我见过太多次的场面:某天下午有人在群里发一句「我们的 key 是不是被人刷了」,然后接下来两个小时全组人都在干同一件事——找那把 key 到底写在哪些地方。找出来八九个位置,改一遍,重启一遍,还漏了一个跑在别人笔记本上的定时脚本。这两个小时里,泄漏的那把 key 一直是有效的。
所以这篇不打算讲「怎么保管好你的密钥」这种正确但没用的话。真正决定你能不能扛住一次泄漏的,是一个非常工程化的指标:从你确认泄漏,到那把 key 彻底失效并且业务不中断,需要多长时间。 能压到五分钟以内,是一种架构能力;压不下来,那你只能祈祷别出事。
一、key 是怎么漏出去的:三条路,没有一条是「有人故意的」
我把见过的泄漏归了归类,绝大多数落在这三条路上,而且几乎都不是内鬼干的,全是日常操作的副产品。
第一条,进了 git。 最经典的是 config.py 里先硬编码一个 key「本地跑通再说」,然后忘了,提交了。更隐蔽的是:你后来发现了,把它从文件里删掉再提交一次——但 git 历史里那一版还在。只要仓库是公开的,或者被 fork 过、被打包发给过外部、CI 缓存过,那把 key 就等于公开了。还有一类是 .env 文件被 git add -A 一把捞进去,因为 .gitignore 里写的是 .env 而实际文件叫 .env.local。
第二条,进了前端产物。 有人为了图省事,在 React/Vue 里直接调用大模型接口,key 通过构建时环境变量注入。构建时注入到前端的东西,就是明文写在 JS bundle 里的,打开开发者工具就能看到,混淆也没用——混淆改的是变量名,字符串常量还在那儿。移动端更麻烦一点,但反编译一个 apk 掏出常量池并不需要什么高深技术。这条路的特点是:泄漏是持续的、面向全互联网的,而且你的日志里看不到任何异常,因为请求确实来自「真实用户」。
第三条,进了日志和截图。 请求失败时把整个 request 对象 print 出来,header 里的 Authorization 就完整落进了日志文件;日志又被收集到某个全公司都能查的平台上。异常上报服务同理,很多 SDK 默认会把 HTTP 请求上下文一起报上去。还有更朴素的:同事在群里贴一张报错截图求助,key 就在那张图的第三行。
这三条路合起来说明一件事:「我们很小心」不是防线。 小心可以把泄漏概率从 10% 降到 2%,但降不到 0,而且团队一扩张就会反弹。真正的防线只有一条——当泄漏发生时,换掉这把 key 的成本足够低,低到你可以毫不犹豫地立刻换。
这个判断有个很实际的推论:如果换 key 要开三个人的会、要改五个仓库、要在半夜发版,那么当有人怀疑泄漏时,团队的第一反应不会是「换掉」,而是「先确认一下是不是真的漏了」。换 key 的成本越高,你的响应就越慢,损失就越大,这是个正反馈。
二、为什么「厂商原生 key 直接往下发」是个结构性错误
很多团队的现状是这样:在平台控制台申请一把 key,塞进公司的配置中心,然后所有服务、所有同事、所有脚本都用它。这套做法在只有一个服务的时候看不出问题,人一多就会同时暴露三个致命缺陷。
缺陷一:换一次要动所有调用方。 一把 key 服务 N 个调用方,轮换就变成一次 N 方协同的发布。这直接把上面说的「响应成本」拉满。
缺陷二:无法定位是谁漏的。 所有请求长得一模一样,你拿到一条异常调用记录,只能知道「有人在用这把 key」,没法知道是哪个服务、哪个人、哪台机器。于是复盘只能靠猜,而猜错的代价是——你补错了洞,下次还漏。
缺陷三:无法只停一个人。 假设你已经高度怀疑是某个外包同学的本地环境漏了,但你手上只有一个开关:停掉这把 key。一停,全公司的 AI 功能一起挂。于是实际操作往往变成「先不停,观察一下」,然后就没有然后了。
这三条指向同一个解法:在你自己和上游厂商之间放一层网关,厂商原生 key 只有网关知道,所有调用方拿到的都是网关签发的虚拟 key。 一个调用方一把,甚至一个人一把。这样一来,上面三个问题分别变成:只需要重签一把、看签发记录就知道是谁、停掉一把不影响别人。关于网关本身的鉴权模型和虚拟 key 的具体签发方式,可以先看 LiteLLM 虚拟 key 的完整用法 和 网关鉴权设计。
顺带说一句常被忽略的收益:网关这层让前端永远不需要持有任何上游 key。前端调用的是你自己的后端,后端再带着虚拟 key 去调网关。上面第二条泄漏路径直接被架构消掉了,而不是靠纪律约束。
三、轮换的正确姿势:双 key 并行期
轮换这件事,方法错了比不做还糟。我见过最常见的错误做法是「先停后换」——把旧 key 停掉,然后改配置发新 key,重启服务。这中间必然有一段时间:旧 key 已经无效,新配置还没生效。这段时间的长度等于你的发布周期,几分钟到几十分钟不等,期间所有调用全部 401。
正确的顺序是反过来的,核心是留一个双 key 并行期:
| 阶段 | 旧 key | 新 key | 关键动作 |
|---|---|---|---|
| 1. 签发 | 有效 | 有效 | 签发新 key,权限与旧 key 对齐 |
| 2. 灰度切换 | 有效 | 有效 | 逐个调用方改配置、重启,一次一个 |
| 3. 观察 | 有效 | 有效 | 确认旧 key 已无新增花费/调用 |
| 4. 停用 | 停用 | 有效 | 停而不删,保留证据与回滚余地 |
| 5. 清理 | 删除 | 有效 | 观察期过后再删 |
这里每一步都有讲究:
第 2 步要一个一个切,不要一把梭。 一次切一个调用方,切完观察它的错误率再切下一个。如果新 key 的权限配错了(比如模型白名单少写了一个),你只会打挂一个服务,而且立刻知道是哪里错了。全量切换出问题时,你面对的是「全线故障 + 不知道是哪个环节错」。
第 3 步是整个流程里最容易被跳过的一步,也是最重要的一步。 你必须有一个客观依据来确认「旧 key 真的没人在用了」,而不是靠「我觉得都改完了」。总有一个你想不到的地方在用旧 key:某台运维机器上的 cron、某个只在月底跑的报表任务、某个同事本地的调试脚本。跳过观察期直接停用,这些东西会在你完全没预期的时候炸。
第 4 步是停用而不是删除。 这个区别在下一节讲,是应急场景里的关键。
关于观察期该多长:取决于你系统里调用频率最低的那个任务。如果有月度批处理,那严格说观察期该跨一个月——现实中很少有人这么干,折中做法是把低频任务单独登记成一张清单,轮换时人工逐条确认,而不是指望观察期覆盖它们。
四、用 LiteLLM 的能力把这套流程落地
LiteLLM Proxy 的虚拟 key 体系刚好提供了上面每一步需要的原语。下面用到的字段和接口都来自官方文档,具体行为以官方文档当次为准,版本会变。
让 key 天生有有效期:duration
签发接口是 POST /key/generate,用 master key 的 Bearer token 鉴权。这里最值得用起来的参数是 duration(字符串),官方文档里出现的示例值形如 "30min"、"30d"。
curl 'http://0.0.0.0:4000/key/generate' \
--header 'Authorization: Bearer <master-key>' \
--header 'Content-Type: application/json' \
--data-raw '{
"models": ["gpt-3.5-turbo", "gpt-4"],
"metadata": {"user": "email@example.com"}
}'
(上面是官方示例的形状,端口 4000 是官方示例里出现的地址,不代表你的部署一定是这个。)
duration 的价值在于把轮换从「一件需要有人记得去做的事」变成「一件不做就会自动出问题的事」。给临时用途的 key 一律带上短 duration:外包同学的调试 key、一次性数据回填脚本的 key、给合作方做 POC 的 key。这类 key 泄漏概率最高、被遗忘概率也最高,让它自己过期,比指望谁记得回收靠谱得多。
长期服务的 key 要不要带 duration,取决于你的自动续签有没有做扎实。没做扎实就别加——半夜因为 key 过期而全线 401,比不轮换更糟。这时候更现实的做法是:duration 只用于人和临时任务,服务用的 key 靠流程定期换。
顺便,metadata(对象)这个字段一定要用起来。签发时把「谁申请的、用在哪个服务、哪个环境」写进去,这是事后定位的唯一线索。别嫌麻烦——出事那天你会庆幸自己写了。相关的权限与配额字段(models 白名单、user_id / team_id 归属、max_budget 花费上限、tpm_limit / rpm_limit 速率上限、aliases 模型名映射)在 虚拟 key 那篇 里有更完整的展开。
先停后查,别急着删:/key/block
LiteLLM 提供了 /key/block 与 /key/unblock 这一对接口,用来停用和重新启用一把 key。这对接口的存在,是「先停后查」这个应急原则能成立的技术前提。
为什么不直接删?两个理由:
一是删了就没证据了。 key 被删除后,你就没法再查它的花费记录和归属信息了。而应急阶段你最需要回答的恰恰是「这把 key 被用来干了什么、花了多少、是谁的」。先 block 住,止血完成,证据还在,慢慢查。
二是留一条回滚路。 有时候你判断错了,那不是泄漏而是某个新上线的服务流量突增。/key/unblock 一下就恢复了。删除是不可逆的,判断错的代价直接变成一次事故。
所以应急时的正确动作永远是 block,不是 delete。删除放在事情彻底结束、复盘写完之后。
确认「真的没流量了」:/key/info 与三层花费追踪
第三步的观察期需要客观依据,/key/info 就是拿这个依据的地方——它可以查这把 key 的花费。
用法很朴素:间隔采样,看花费还涨不涨。切换完成后隔一段时间查一次,如果花费数字停止增长,说明没有新调用进来了。这比问一圈「你们都改完了吗」可靠得多,因为它是系统客观记录,不依赖任何人的记忆。
再往上一层,LiteLLM 官方说明花费追踪是通过数据库在 key / user / team 三个层级自动进行的。这三层是定位问题的骨架:
- key 层:定位到具体那一把凭证,回答「是哪把 key 在花钱」;
- user 层(对应签发时的
user_id):定位到人,回答「是谁的 key」; - team 层(对应
team_id):定位到组织单元,回答「哪个团队/业务线的成本在异常增长」。
这个结构的实用价值是:你能在还没确定是哪把 key 之前,先从 team 层看出「哪块业务不对劲」,然后往下钻。 反过来说,如果你签发 key 时偷懒,user_id / team_id / metadata 全空着,那三层追踪就退化成一层,泄漏时你只能看到一个孤零零的 key ID,什么都定位不出来。
这件事必须在签发的时候做,事后补不了。 这是我觉得整个密钥管理里最容易被低估的一条纪律:签发时多花的三十秒,换的是出事时省下的三小时。更系统的凭证组织方式可以参考 接入密钥的管理实践。
五、泄漏应急五步,带时间线要求
真出事的时候没人有心情翻文档。把下面这个流程提前写成一页纸贴在 wiki 上,出事时照着做。
第 1 步:立即 block(目标:确认后 5 分钟内)
调 /key/block 停掉可疑 key。不要先开会讨论是不是真漏了——如果你的架构做对了(一个调用方一把 key),停一把 key 的爆炸半径是可控的,误停的代价远小于晚停。这一步的心理门槛是整个流程里最关键的:能不能不假思索地按下这个按钮,取决于你前面的架构做得好不好。
如果停下来发现自己犹豫了——「这把 key 是不是好几个服务在用啊」——那么恭喜,你刚刚发现了一个比这次泄漏更严重的问题,记进复盘。
第 2 步:查花费与来源(目标:15 分钟内出结论)
用 /key/info 看这把 key 的累计花费,对照它正常情况下的花费水平。同时用 key / user / team 三层追踪往上看,确认这把 key 归属于谁、绑在哪个团队、metadata 里记的是什么用途。
判断的核心不是「花费绝对值大不大」,而是**「花费形态对不对」**:一把只给夜间批处理用的 key 在白天产生花费,即使金额很小,也是明确的异常信号。
第 3 步:评估影响面(目标:30 分钟内)
要回答三个问题:这把 key 的 models 白名单包含哪些模型(能力边界)、有没有设 max_budget(损失上限)、它服务的业务现在是不是停了(业务影响)。
如果签发时设过 max_budget,这一步会轻松很多——你的最大损失已经被这个数字封顶了。没设过的话,把「所有 key 必须设 max_budget」写进复盘的改进项。 预算上限的意义就在这种时刻:它把一次安全事件的财务后果,从「未知」变成「一个你事先接受过的数字」。
第 4 步:签发新 key 并切换(目标:1 小时内恢复业务)
用 /key/generate 签一把新的,models、max_budget、tpm_limit、rpm_limit 这些照着旧 key 的配置对齐,别趁乱改权限——应急时改权限,等于在故障里叠加一个变更,排查会变成灾难。metadata 里标注清楚这是应急替换及日期。
然后按第三节的流程切换调用方。注意这里和常规轮换有个区别:常规轮换有双 key 并行期,应急没有,旧 key 已经被 block 了。所以应急切换是有业务中断的,这也是为什么第 1 步的爆炸半径控制这么重要——它直接决定了这次中断影响多少人。
第 5 步:复盘泄漏路径(目标:48 小时内)
必须回答「它是怎么出去的」,而不只是「我们换掉了」。回到第一节那三条路挨个排查:翻 git 历史(包括已删除文件的历史版本)、检查前端构建产物里有没有明文常量、grep 日志与异常上报里的敏感 header。
找不到路径的话,这次事故不算结束。 因为你没有任何理由相信同一个洞不会在下周再漏一次。这种时候可以采取一个笨但有效的办法:把该业务线所有 key 全部轮换一遍,并且把新 key 全部带上短 duration,用时间换排查空间。
六、预防清单:把泄漏概率压下去的六件事
应急做得再好,也不如少出事。这六条是投入产出比最高的:
1. 密钥只从环境变量或密钥管理服务注入,代码里不出现任何密钥字面量。 这条要卡在 code review 里,写进团队规约。配套做法是提供一个统一的读取函数,让「正确的做法」比「硬编码」更省事——如果安全做法比不安全做法更麻烦,规约就一定会被绕过。
2. .gitignore 覆盖所有环境文件变体,并在提交前做扫描。 注意变体问题:.env、.env.local、.env.production 得都覆盖到,最好用 .env* 这类通配写法再单独放行 .env.example。提交前扫描建议做成 pre-commit 钩子和 CI 检查两道——本地钩子会被 --no-verify 绕过,CI 那道才是真正的拦截线。具体用什么工具做扫描,看你的技术栈和 CI 平台的支持情况自行选型。
3. 日志脱敏,在日志框架层做,不要靠调用点自觉。 关键是位置:脱敏逻辑必须放在日志输出的公共路径上(格式化器、拦截器、中间件),做成默认行为。指望每个打日志的人记得抹掉 Authorization 是不可能的,一百个打点里漏一个就等于没做。
4. 错误上报同样脱敏。 异常上报 SDK 通常会把 HTTP 上下文一起打包上报,这是很多人完全没意识到的一条泄漏路径——数据出了公司网络,进了第三方平台。接入这类服务时,专门去确认它的敏感字段过滤配置怎么写。
5. 前端永不持有上游 key,必须走自己的后端中转。 这条没有例外,也不存在「用混淆保护一下」的折中方案。前端能拿到的东西,用户就能拿到。附带好处是中转层天然是个限流和审计点。
6. 一个调用方一把 key,签发时填全归属信息。 user_id、team_id、metadata 都填上,max_budget 设上,临时用途带 duration。这条是前面所有应急能力的地基——地基没打,第五节那套流程一步都走不通。更多网关侧的加固措施见 网关安全。
自查清单
拿这七条对着你现在的系统过一遍,答不上来的就是待办:
- 你能说出线上正在使用的每一把 key 分别属于哪个调用方吗?说不出来的,先做一次盘点。
- 把一把 key 换掉,从决定到完成需要多久?超过一小时的,问题在架构不在流程。
- 有没有任何一把 key 是被两个以上调用方共用的?有的话,它就是你的单点。
- 前端构建产物里有没有任何上游凭证的明文?现在就去 bundle 里搜一遍。
- 日志脱敏是在框架层做的,还是靠每个打点自觉?后者等于没做。
- 每把 key 是否都设了
max_budget?没设的话,你的单次事故损失上限是多少,你答得出来吗? - 临时用途的 key 有没有带
duration?半年前给外包发的那把,现在还有效吗?
上面涉及的接口与字段以官方文档当次为准,版本更新时记得回去核一遍。但真正决定成败的不是记住哪几个接口名,而是那个朴素的问题:今天下午如果有人告诉你 key 漏了,你的第一反应是「立刻停掉」,还是「先别急,我确认一下」?