最常见的一条。本地测试图省事,key 直接写在 main.py 顶部,git add . 一把推上去。更麻烦的是你以为删掉那一行就完事——那一次 commit 还在历史里,git log -p 一搜就出来,GitHub 的 commit 页面还能直接打开。公开仓库从 push 到被扫走,通常按分钟计。
React/Vue 项目把 key 写进以 VITE_、REACT_APP_、NEXT_PUBLIC_ 开头的环境变量,构建后它会以明文出现在打包的 JS 文件里。打开 F12 搜 sk- 就能找到。这类前缀在设计上就是"给浏览器看的",等于公开。
在群里发报错截图,终端里 echo $XYU_API_KEY 的输出被一起截进去;录教程视频时编辑器里开着 .env;提问时把 curl -H "Authorization: Bearer sk-..." 整条贴到论坛。这些泄露和推到 GitHub 一样致命,只是当事人毫无感觉。
日志是更隐蔽的一条。很多人把 key 放进请求头,然后顺手把整个请求对象打进了日志文件。错误上报平台更麻烦——异常堆栈里带上完整请求对象,key 就跟着飞到第三方服务器上了。日志必须脱敏:打印前统一把 Authorization 头替换成 Bearer ***,错误上报 SDK 也要配同样的过滤器,否则你的 key 会在别人家的后台里安静地躺着。
任何让你把 key 填进去的第三方客户端,理论上都能读走它。尤其警惕"免费 API 加速""共享 key 池""低价充值"这类服务,它们要的往往就是你的 key。选客户端至少确认它是开源、本地运行、有明确数据说明的。
发现泄露的第一反应应该是去后台禁用或删除这个 key,而不是去删仓库、删帖子。删帖只能挡住新的扫描器,已经被抓走的 key 还在别人机器上继续跑。动作要落在 key 本身:后台删除该 key,或者把它的额度立刻改成 0。
查最近的调用记录:时间、模型、请求数、扣费金额。重点看有没有你从没调过的模型、有没有集中在凌晨的批量请求、有没有单次几万 token 的异常调用。截图存下来,一是算损失,二是申诉时用得上。
顺着来源查。如果是 GitHub,看仓库是不是公开、有没有被 fork、有没有打进 release 包;如果是前端,去打包产物里再搜一遍 key;如果是截图,撤回或删除,并通知已经看过的人。同一个 key 常常同时存在于多个地方,只灭一处等于没灭。
先禁用,再排查。密码和 key 都是这个顺序。
# 1. 全仓库查残留 key(包含 git 全部历史)
git log -p --all | grep -nE 'sk-[A-Za-z0-9_-]{16,}'
grep -rnE 'sk-[A-Za-z0-9_-]{16,}' . --exclude-dir=node_modules --exclude-dir=.git
# 2. 查打包产物(前端项目)
grep -roE 'sk-[A-Za-z0-9_-]{16,}' dist/ || echo "打包产物干净"
# 3. 清掉当前 shell 里还生效的旧值
unset XYU_API_KEY
# 4. 新 key 只写进 .env(已在 .gitignore),不落任何代码文件
公开仓库里的 API key 从推送完成到被自动扫描器抓到,普遍在几分钟之内,快的不到一分钟。这些机器人挂在 GitHub 的公开事件流上,只要有新 commit 就立刻拉下来跑正则,7×24 不停。它们不挑目标、全量扫,"我仓库没人看"这个前提根本不成立——扫你的不是人,是机器。
git rm 再提交一次,旧 commit 仍然在历史里,github.com/用户名/仓库/commit/<sha> 这个地址照样能打开看到 key。GitHub 的缓存、第三方镜像、同事本地那份 clone,都还留着。唯一的正解是:把那个 key 当成已经泄露,先吊销,再重写历史。
# 吊销 key —— 永远是第一步,其他动作都在它之后
pip install git-filter-repo
# 把敏感文件从全部历史里移除
git filter-repo --path config/.env --invert-paths
# 或者把整个历史里的 key 字符串替换掉
git filter-repo --replace-text <(echo 'sk-泄露的旧key==>REMOVED')
# filter-repo 会清掉 remote,需要重新加回来,然后强制覆盖远端
git remote add origin git@github.com:you/repo.git
git push --force --all
git push --force --tags
重写历史之后,所有协作者必须重新 clone。他们本地那份旧历史只要一推,key 又回来了。
# ❌ 一旦提交就是永久泄露
from openai import OpenAI
client = OpenAI(
api_key="sk-abc123def456ghi789jkl012",
base_url="https://xyuapi.top/v1",
)
// ❌ 前端硬编码:构建后明文躺在 JS 里,F12 搜 sk- 就能找到
const client = new OpenAI({
apiKey: import.meta.env.VITE_API_KEY,
baseURL: "https://xyuapi.top/v1",
dangerouslyAllowBrowser: true,
});
# ✅ 代码可以随便公开,key 在环境里
import os
from openai import OpenAI
client = OpenAI(
api_key=os.environ["XYU_API_KEY"], # 缺失时直接 KeyError,比静默失败好排查
base_url=os.environ.get("XYU_BASE_URL", "https://xyuapi.top/v1"),
)
# .env(必须进 .gitignore,仓库里只放 .env.example)
XYU_API_KEY=sk-你的密钥
XYU_BASE_URL=https://xyuapi.top/v1
# 本地开发时加载 .env;生产环境走系统环境变量或密钥管理服务,不读文件
from dotenv import load_dotenv
load_dotenv()
浏览器里永远不放 key。前端请求打到自己的服务端,由服务端加上 key 转发出去。
// server/proxy.js —— Express 转发,浏览器全程看不到 key
import express from "express";
import rateLimit from "express-rate-limit";
const app = express();
app.use(express.json());
app.use(rateLimit({ windowMs: 60_000, max: 30 })); // 每分钟 30 次,防刷
app.post("/api/chat", async (req, res) => {
const r = await fetch("https://xyuapi.top/v1/chat/completions", {
method: "POST",
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${process.env.XYU_API_KEY}`, // key 只在服务端
},
body: JSON.stringify({
model: "claude-sonnet-4-5-thinking", // 模型由服务端定,前端改不了
messages: req.body.messages,
max_tokens: 1024,
}),
});
if (!r.ok) return res.status(r.status).json({ error: "上游异常" });
res.json(await r.json());
});
app.listen(5010);
// 前端:URL 里既没有 key,也没有上游地址
await fetch("/api/chat", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ messages: [{ role: "user", content: "你好" }] }),
});
.env
.env.*
!.env.example
*.pem
config/secret*
光靠 .gitignore 不够,已经被 add 过的文件不受影响。再加一道提交前检查:
# .git/hooks/pre-commit (chmod +x 后生效)
#!/bin/sh
if git diff --cached -U0 | grep -nE 'sk-[A-Za-z0-9_-]{16,}'; then
echo "检测到疑似 API Key,提交已中止,请改用环境变量。"
exit 1
fi
团队项目直接上 gitleaks,在 CI 里扫全历史:
docker run --rm -v "$PWD:/repo" zricethezav/gitleaks:latest detect --source=/repo -v
在后台给每个 key 分配额度上限,而不是共用一个大余额。做实验的 key 给 5 元,跑生产的 key 单独给,额度用尽自动停。这样最坏情况的损失是可计算的,不是无限大。小鱼API 这类聚合平台支持多 key 独立额度,充 7 元就能建几个分开用。
平台额度是最后一道闸,应用层应该更早拦住异常消耗。
import json, os, threading, time
BUDGET_FILE = "budget.json"
DAILY_LIMIT = 20.0 # 单 key 每天最多烧 20 元
PRICE = {"gemini-2.5-pro": 0.031, "claude-sonnet-4-5-thinking": 0.09}
_lock = threading.Lock()
def _load():
if not os.path.exists(BUDGET_FILE):
return {}
return json.load(open(BUDGET_FILE, encoding="utf-8"))
def today_spent():
return _load().get(time.strftime("%Y-%m-%d"), 0.0)
def charge(model):
with _lock:
d = time.strftime("%Y-%m-%d")
data = _load()
data[d] = round(data.get(d, 0.0) + PRICE.get(model, 0.1), 4)
json.dump(data, open(BUDGET_FILE, "w", encoding="utf-8"), ensure_ascii=False)
return data[d]
def guarded_chat(client, model, messages):
if today_spent() >= DAILY_LIMIT:
raise RuntimeError(f"今日预算已用尽({DAILY_LIMIT} 元),请求被熔断")
resp = client.chat.completions.create(model=model, messages=messages)
spent = charge(model)
if spent >= DAILY_LIMIT * 0.8:
print(f"今日已用 {spent:.2f} 元,接近上限,请检查调用来源")
return resp
这段代码的价值不在省钱,在于把"发现异常"的时间点从月底看账单提前到当天告警。key 被盗后几小时就能烧掉一大笔,熔断能把损失锁死在一个数量级内。
额度上限解决的是"最多亏多少",不解决"多久能发现"。阈值按日常消耗的三倍设:平时一天花 2 元,告警线就放 6 元,一超线立刻去查是谁在调。只看金额还不够,再补两个特征一起盯:同一分钟里的请求数突然翻几倍,以及调用记录里出现了你代码里根本没写过的模型名。三个信号里任意两个同时出现,基本可以判定为异常消耗,这时候主动吊销,比等到月底看账单早得多。
一个项目一个 key,命名写清用途,比如 prod-web-chat、batch-summarize、local-dev。出问题时能立刻定位是哪个项目泄露、影响范围多大,而不是所有业务一起停摆。
要调生图模型的 key 单独建——gpt-image-2-pro 0.25 元/次、NanoBanana-Pro 0.27 元/次,单次成本是文本模型的 5 到 8 倍,被盗损失最快。实验性 key 只挂便宜模型,比如 gemini-2.5-pro(0.031)和 deepseek-v3.2-thinking(0.049),额度给到 5 元封顶。
设备维度同样要拆:笔记本、服务器、CI 流水线各用各的 key。CI 里的 key 尤其危险,它会出现在构建日志里,要么设成短时效,要么每次构建前生成、构建后立即吊销。
给 key 分组之前,先按单次成本把模型排一遍,贵模型一律不许挂到调试 key 上:
| 模型 | 单价 | 该挂在哪类 key |
|---|---|---|
| gpt-5.3-pro | 0.3 元/次 | 仅生产 key,额度单列 |
| NanoBanana-Pro(生图) | 0.27 元/次 | 单独 key,额度压到最低 |
| claude-opus-4-7-thinking | 0.25 元/次 | 仅生产 key,额度单列 |
| claude-opus-4-6-thinking | 0.25 元/次 | 仅生产 key,额度单列 |
| gpt-image-2-pro(生图) | 0.25 元/次 | 单独 key,额度压到最低 |
| claude-sonnet-4-6-thinking | 0.2 元/次 | 生产 key 或常用 key |
| gpt-5.5-pro-thinking | 0.2 元/次 | 生产 key 或常用 key |
| claude-opus-4-5-thinking | 0.12 元/次 | 常用 key 可挂 |
| claude-sonnet-4-5-thinking | 0.09 元/次 | 常用 key 可挂 |
| deepseek-v4-pro-thinking | 0.09 元/次 | 常用 key 可挂 |
| grok-4.1 | 0.05 元/次 | 常用 key 可挂 |
| deepseek-v3.2-thinking | 0.049 元/次 | 调试 key 默认挂 |
| gemini-2.5-pro | 0.031 元/次 | 调试 key 默认挂 |
排在 0.2 元以上的那几个,一次泄露被刷一万次就是两三千块。它们的 key 要么单独建、额度单列,要么干脆不放长驻环境里,用一次建一次。
"大家先用同一个 key,省事"是事故的标准开头。一旦泄露,你没法定位是谁的机器、哪个环节出的问题,只能全员换 key、全员停服。正确做法是每人一个 key,加一张登记表,出事直接锁定到人,影响面也限于一个人。
不用复杂,一个 markdown 文件就够:
| Key 名称 | 用途 | 绑定模型 | 额度上限 | 创建日期 | 状态 |
|---|---|---|---|---|---|
| prod-web-chat | 线上对话 | claude-sonnet-4-5-thinking | 100 元 | 2026-09-01 | 启用 |
| batch-summarize | 批处理 | gemini-2.5-pro | 30 元 | 2026-09-05 | 启用 |
| local-dev | 本地调试 | deepseek-v3.2-thinking | 5 元 | 2026-09-10 | 启用 |
| ci-build | CI 流水线 | gemini-2.5-pro | 10 元 | 2026-09-12 | 停用 |
这张表放密码管理器或私有仓库,绝不能放公开仓库。每季度清一次三个月没动过的 key。
这个判断不做,后面全是白忙。看三个特征:时间分布上,盗刷会集中在凌晨和你不办公的时段;模型分布上,会出现你代码里从没写过的模型名;请求特征上,表现为固定间隔的高频批量调用,或者单次上下文特别长。自己程序出 bug 通常是死循环,特征是持续高速调用但模型集中、时间连续。
线上业务直接删 key 会让服务整个挂掉。更稳的顺序是:把额度临时压到很低(比如 1 元)先止住损失,同时保持服务可用;并行生成新 key,灰度把流量切过去;确认新 key 工作正常后再删除旧的。整个过程业务不中断,而盗刷方最多再花掉 1 元。
材料齐了再提,比反复沟通快得多。同时把这件事记进团队的复盘文档:这次是从哪个入口漏的、多久发现的、为什么没被更早拦住。同一个入口漏第二次,那就是流程问题不是运气问题。
sk-,范围包括 git 历史、打包产物、日志文件.env 已在 .gitignore 里,仓库里只留 .env.example最容易被跳过的是轮换:key 每三个月换一次。轮换本身不难,难的是记得做,把它设成日历上的重复事件,比靠记性可靠。定期清理同样重要——三个月没调用过的 key 直接删掉,它的存在只会增加泄露面。