大模型 API Key 泄露的后果很直接:别人用你的额度,账单记在你头上。轻则几天额度烧光,重则被拿去跑灰产,账号直接封禁。
泄露路径没有想象中多,绝大多数是这三条。
一、路径一:打包进前端(最常见,也最致命)
这个错误在 Vite / Next.js / Create React App 项目里高频出现:
// 前端代码里直接调
const res = await fetch('https://api.openai.com/v1/chat/completions', {
headers: { Authorization: `Bearer ${import.meta.env.VITE_OPENAI_KEY}` }
})
看起来用了环境变量,很安全。但 VITE_ 前缀的含义是「注入到客户端」——构建时这个值会被字面替换进打包产物,任何人在浏览器里按 F12、或者在 dist 目录里 grep 一下,就能拿到明文。
同类前缀:
| 框架 | 会注入前端的变量前缀 |
|---|---|
| Vite | VITE_* |
| Create React App | REACT_APP_* |
| Next.js | NEXT_PUBLIC_* |
| Nuxt | NUXT_PUBLIC_* |
无前缀的 PUBLIC_*(Astro 等) |
同 |
判断标准只有一条:这个 key 需不需要出现在浏览器网络请求里。 需要,就必然泄露;不需要,就不该打包进去。
正确做法是加一层自己的后端:
浏览器 → 你的后端(校验登录、限流、记日志) → 模型厂商
Key 只存在后端环境变量里。多写 30 行代理代码,比事后处理账单便宜得多。
自查命令(在构建产物目录跑):
# 找 sk- 开头的疑似密钥
grep -rnoE 'sk-[A-Za-z0-9_-]{20,}' dist/ | head
# 找所有疑似 key 的赋值
grep -rnoE '(api[_-]?key|token|secret)["'"'"']?\s*[:=]\s*["'"'"'][^"'"'"']{16,}' dist/ | head
二、路径二:提交进 Git
.env 加进 .gitignore 之前的第一次提交,是最经典的泄露。
要命的是:删掉文件不等于删掉记录。Git 保存的是历史快照,git rm 之后老提交里照样能看到。
# 检查当前所有文件里有没有
git grep -nE 'sk-[A-Za-z0-9_-]{20,}' $(git rev-list --all) 2>/dev/null | head
# 更快的办法:只查历史里出现过的疑似 key
git log -p --all -S 'sk-' --oneline | head -20
已经推到公开仓库的处理顺序:
- 立刻去厂商后台吊销这个 key(不是改密码,是吊销/删除 key)
- 新建 key,只配到服务器环境变量
- 检查用量和账单,确认是否有异常调用
- 清理 Git 历史(
git filter-repo或 BFG) - 私有仓库也要做——团队成员、离职人员、备份都可能泄露
清理历史这一步不能省,但优先级排在吊销之后。 因为 public 仓库的提交在几分钟内就会被自动化爬虫扫走,清理历史只是止损,吊销才是止血。
三、路径三:日志、报错、截图
这一类最容易被忽略,因为「不是代码问题」。
| 场景 | 泄露方式 |
|---|---|
console.log(config) |
整个配置对象被打印,含 key |
| 异常堆栈带请求头 | 错误上报平台收到完整 header |
| Nginx access log 记了 query | ?api_key=xxx 原样落盘 |
| Postman / curl 截图发群 | 肉眼可见 |
| 第三方代理 / 中转站 | key 明文经过对方服务器 |
| 浏览器插件 | 读页面和请求 |
最后一条值得单独说:用第三方「中转 API」等于把 key 交给对方。价格便宜的中转站,商业模式往往就是把你的 key 留下来自己用。要用就单独申请一个专用 key,设最低额度,用完即删。
日志脱敏的最小实现:
const MASK = (v) => (typeof v === 'string' && v.length > 8
? v.slice(0, 4) + '****' + v.slice(-4)
: '****')
// 打印前过一遍
console.log(JSON.stringify(maskSecrets(config), null, 2))
function maskSecrets(obj) {
const KEYS = /^(api[_-]?key|authorization|token|secret|password)$/i
if (Array.isArray(obj)) return obj.map(maskSecrets)
if (obj && typeof obj === 'object') {
return Object.fromEntries(
Object.entries(obj).map(([k, v]) => [k, KEYS.test(k) ? MASK(v) : maskSecrets(v)])
)
}
return obj
}
注意 Nginx 那一条:如果 key 走 query string,access.log 会完整记录。接口设计上永远不要把密钥放 URL,放 header。
四、泄露后十分钟:按这个顺序做
顺序错了会扩大损失。
| 分钟 | 动作 | 为什么这个顺序 |
|---|---|---|
| 0–1 | 吊销 key | 止损,比什么都优先 |
| 1–3 | 查用量曲线,确认异常时间段和调用量 | 判断影响范围,也是申诉证据 |
| 3–5 | 新建 key,只配到服务端 | 恢复服务 |
| 5–8 | 查是否有数据外泄(不只是钱) | 如果 key 能访问私有数据,性质变了 |
| 8–10 | 关掉日志里的明文、删掉公开仓库的提交 | 防止二次泄露 |
「先查明原因再吊销」是错的。原因可以事后查,key 每一分钟都在被使用。
吊销之后如果发现额度已经被大量消耗,去厂商后台提工单,附上异常时间段的调用记录。多数厂商对「密钥泄露导致的异常调用」有减免政策,但要主动申请,不会自动退。
五、四道闸门,防止再犯
闸门一:密钥分级。 一个用途一个 key,不要一个 key 走遍所有环境。开发、测试、生产分开;前端代理用的 key 只给代理服务;离线批处理单独一个,设最低额度。
闸门二:额度上限。 每个 key 设月度硬上限。这一条最有用——泄露了最多损失一个上限,而不是全部余额。
闸门三:提交前拦截。 用 pre-commit 钩子扫一遍:
# .git/hooks/pre-commit
#!/bin/sh
if git diff --cached | grep -qE 'sk-[A-Za-z0-9_-]{20,}'; then
echo "❌ 提交内容里疑似含 API Key,已阻止"
exit 1
fi
再加一道远程的:GitHub 的 secret scanning、Gitee 的密钥检测,都免费且自动。
闸门四:构建期检查。 在 CI 里扫打包产物,防止「本地没事、上线带 key」:
grep -rqE 'sk-[A-Za-z0-9_-]{20,}' dist/ && { echo "产物里发现密钥"; exit 1; }
六、自查用的正则
上面那些 grep 命令里的正则,可以在线验证效果:
sk-[A-Za-z0-9_-]{20,}
想覆盖更多家的格式:
(sk|ak|pk)-[A-Za-z0-9_-]{16,}|AIza[0-9A-Za-z_-]{35}|xox[baprs]-[A-Za-z0-9-]{10,}
AIza 是 Google API Key 的前缀,xox 是 Slack 的。这类正则拿去跑一次全仓库,通常能翻出几个忘了删的测试 key。
七、一个思维实验
问自己一句:如果现在把这个项目的全部代码贴到公开论坛,会泄露什么?
答案里包含任何能换钱的凭据,就说明它不该以现在的形式存在。这个方法比任何检查清单都快,因为它不依赖你记得所有规则。
八、小结
- 前端
VITE_/NEXT_PUBLIC_前缀的变量就是公开的,别放 key - 删文件删不掉 Git 历史,吊销 key 才是止血
- 日志、截图、中转站,三条不写代码也会泄露的路
- 处置顺序:吊销 → 查用量 → 换新 → 查数据 → 清日志
- 四道闸门里,额度上限性价比最高
密钥泄露从来不是「会不会」的问题,是「什么时候」。区别只在于:发现时是损失 20 块,还是 2 万。
相关工具:正则测试|哈希计算|Base64 编解码|文本对比