API Key 泄露了怎么办:三条最常见的泄露路径与十分钟处置流程

大模型 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

已经推到公开仓库的处理顺序:

  1. 立刻去厂商后台吊销这个 key(不是改密码,是吊销/删除 key)
  2. 新建 key,只配到服务器环境变量
  3. 检查用量和账单,确认是否有异常调用
  4. 清理 Git 历史(git filter-repo 或 BFG)
  5. 私有仓库也要做——团队成员、离职人员、备份都可能泄露

清理历史这一步不能省,但优先级排在吊销之后。 因为 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 编解码|文本对比

还有 66 个免费在线工具

纯前端实现,不用注册,数据不上传服务器。

浏览全部工具