19.08-API Key保护
要点
- API Key 是服务间认证最常用的方式——简单、无状态、适合服务端到服务端通信
- API Key 泄露是最常见的安全事故之一——GitHub 提交、日志输出、前端代码、错误信息都可能泄露 Key
- 保护原则:Key 只存在服务端环境变量里、前端永远不持有服务端 Key、Key 按服务/用途独立分配
- Cloudflare Workers 的 Secrets 机制专门保护 API Key——加密存储、不会出现在日志和调试输出里
- Key 轮转策略:定期更换 + 支持新旧 Key 同时有效(平滑过渡)
- 给第三方发的 API Key 需要权限控制和限流——不是所有 Key 都一样
1. API Key 的使用场景
API Key 适用于服务端到服务端的认证——一个服务调用另一个服务的 API。
你的 Worker → 调用 OpenAI API(Authorization: Bearer sk-xxx)
你的 Worker → 调用 Cloudflare API(X-Auth-Key: xxx)
第三方服务 → 调用你的 API(X-API-Key: xxx)API Key 不适合用户级认证——用户登录应该用 JWT 或 Session,不应该给每个用户发 API Key。
1.1 API Key vs JWT
| 维度 | API Key | JWT |
|---|---|---|
| 持有者 | 服务/应用 | 用户 |
| 生命周期 | 长期有效(手动轮转) | 短期有效(自动过期) |
| 粒度 | 服务级(整个服务共用一个 Key) | 用户级(每个用户一个 token) |
| 撤销 | 需要更新 Key(影响所有使用该 Key 的请求) | 黑名单或直接过期 |
| 传输方式 | Header(Authorization / X-API-Key) | Header(Authorization: Bearer) |
| 适用场景 | 服务间调用、第三方 API 访问 | 用户登录、前端认证 |
2. API Key 的常见泄露途径
2.1 代码仓库
最常见的泄露方式——把 API Key 硬编码在代码里,提交到 GitHub:
// 绝对不要这样做
const OPENAI_KEY = 'sk-xxxxxxxxxxxxxxxxxxxxxxxx'# .gitignore 必须包含的文件
.env
.env.local
.env.*.local# 更安全的做法:用 pre-commit hook 检测密钥
# 安装 gitleaks 或 git-secrets
git-secrets --register-aws
git-secrets --add 'sk-[a-zA-Z0-9]{20,}'2.2 前端代码
前端代码对用户完全可见——任何放在前端的 Key 都是公开的:
// 危险:前端代码里的 API Key
// src/app/page.tsx(客户端组件)
'use client'
const API_KEY = 'sk-xxx' // ← 用户打开 DevTools 就能看到正确做法:前端只调你自己的后端 API,后端再带 Key 调第三方服务。
// 正确:前端 → 你的后端 → 第三方服务
// 前端代码
fetch('/api/ai/chat', {
method: 'POST',
body: JSON.stringify({ prompt: '...' }),
})
// 你的 Worker(Server Component 或 Route Handler)
const OPENAI_KEY = process.env.OPENAI_KEY // 只在服务端
await fetch('https://api.openai.com/v1/chat/completions', {
headers: { 'Authorization': `Bearer ${OPENAI_KEY}` },
})2.3 日志和错误信息
API Key 出现在日志里——开发者排查问题时看日志,Key 就泄露了:
// 危险:日志里打印了完整 Key
console.log('Calling API with key:', apiKey)
console.error('API error:', error, { key: apiKey, response: data })// 正确:只打印 Key 的前几位
console.log('Calling API with key:', apiKey.slice(0, 8) + '...')
// 输出:Calling API with key: sk-abc12...2.4 URL 参数
API Key 放在 URL 参数里,可能被浏览器历史记录、代理日志、Referer 头记录:
// 危险:Key 在 URL 里
fetch(`https://api.example.com/data?api_key=${apiKey}`)
// 正确:Key 在 Header 里
fetch('https://api.example.com/data', {
headers: { 'X-API-Key': apiKey },
})2.5 错误响应
服务端错误响应里泄露了 API Key 或类似的内部信息:
// 危险
try {
await callExternalAPI(apiKey)
} catch (e) {
return c.json({
error: e.message, // ← 可能包含 Key
apiKey: apiKey, // ← 直接泄露
stack: e.stack,
}, 500)
}3. Cloudflare Workers Secrets
Workers 提供了专门的 Secrets 机制保护 API Key:
3.1 什么是 Secrets
Secrets 是环境变量的一种特殊类型:
- 加密存储——代码和配置里看不到明文
- 不会出现在日志里——
console.log(env.API_KEY)输出[redacted] - 不能通过 API 读取——只能通过 Worker 运行时访问
- 按环境隔离——production、staging、preview 各有独立的 Secrets
3.2 设置 Secrets
# 通过 wrangler CLI
wrangler secret put OPENAI_API_KEY
# 交互式输入 Key 的值
# 查看已设置的 Secrets 列表(只显示名字,不显示值)
wrangler secret list
# 删除 Secret
wrangler secret delete OPENAI_API_KEY或者通过 Cloudflare Dashboard → Workers → Settings → Variables and Secrets → Add Secret。
3.3 在代码中使用
// wrangler.toml
# 声明 Secrets(值在 Cloudflare 里设置)
# [secrets]
# OPENAI_API_KEY
# CLOUDFLARE_R2_TOKEN
// src/worker.ts
export default {
async fetch(request: Request, env: Env): Promise<Response> {
// env 里有 Secrets,类型声明在 Env 接口里
const apiKey = env.OPENAI_API_KEY
const response = await fetch('https://api.openai.com/v1/chat/completions', {
method: 'POST',
headers: {
'Authorization': `Bearer ${apiKey}`,
'Content-Type': 'application/json',
},
body: JSON.stringify({ /* ... */ }),
})
return response
}
}
// Env 类型
interface Env {
OPENAI_API_KEY: string // Secret
CLOUDFLARE_R2_TOKEN: string // Secret
R2_BUCKET: R2Bucket // Binding
}3.4 Secrets vs 普通环境变量
| 特性 | Secrets | 普通环境变量 |
|---|---|---|
| 存储 | 加密 | 明文 |
| 日志输出 | [redacted] | 明文显示 |
| Dashboard 可见 | 只能看到名字 | 能看到值 |
| wrangler.toml | 不放值 | 放值 |
| 适用 | API Key、数据库密码、JWT Secret | 功能开关、域名、配置值 |
原则:所有敏感值都用 Secrets,非敏感值用普通环境变量。
4. API Key 设计(给第三方发的 Key)
如果你的 API 要给第三方开发者发 API Key,需要考虑:
4.1 Key 生成
// 生成 API Key
function generateAPIKey(): string {
// 前缀标识来源 + 随机字符串
const prefix = 'ak'
const random = crypto.randomUUID().replace(/-/g, '')
return `${prefix}_${random}`
// 输出:ak_550e8400e29b41d4a716446655440000
}
// 存储 Key 的 hash(不存明文)
async function hashAPIKey(key: string): Promise<string> {
const buffer = await crypto.subtle.digest(
'SHA-256',
new TextEncoder().encode(key)
)
return Array.from(new Uint8Array(buffer))
.map(b => b.toString(16).padStart(2, '0'))
.join('')
}4.2 Key 验证中间件
// API Key 认证中间件
async function apiKeyAuth(c: Context, next: Next) {
const apiKey = c.req.header('X-API-Key')
if (!apiKey) {
return c.json({ error: 'Missing API Key' }, 401)
}
// 用 Key 的前 8 位快速查找(避免全表 hash)
const prefix = apiKey.slice(0, 8)
const records = await c.env.DB
.prepare('SELECT * FROM api_keys WHERE key_prefix = ? AND is_active = 1')
.bind(prefix)
.all()
// 逐个校验 hash
for (const record of records.results) {
const hash = await hashAPIKey(apiKey)
if (hash === record.key_hash) {
c.set('apiKeyOwner', record)
return next()
}
}
return c.json({ error: 'Invalid API Key' }, 401)
}4.3 Key 权限控制
不同的 Key 可以有不同的权限:
// D1 schema
CREATE TABLE api_keys (
id INTEGER PRIMARY KEY,
key_prefix TEXT NOT NULL,
key_hash TEXT NOT NULL,
name TEXT NOT NULL, -- "Production", "Testing"
permissions TEXT NOT NULL, -- JSON: ["read", "write"]
rate_limit INTEGER DEFAULT 1000, -- 每分钟请求数
is_active INTEGER DEFAULT 1,
created_at TEXT DEFAULT (datetime('now')),
expires_at TEXT -- NULL = 永不过期
);
// 权限检查
async function checkPermission(c: Context, permission: string) {
const keyOwner = c.get('apiKeyOwner')
const permissions = JSON.parse(keyOwner.permissions)
if (!permissions.includes(permission)) {
throw new Error(`API Key does not have ${permission} permission`)
}
}
// 使用
app.post('/api/data', apiKeyAuth, async (c) => {
try {
await checkPermission(c, 'write')
} catch (e) {
return c.json({ error: e.message }, 403)
}
// 业务逻辑...
})4.4 Key 限流
不同 Key 可以有不同的限流配额:
// API Key 限流中间件(在认证之后)
async function apiKeyRateLimit(c: Context, next: Next) {
const keyOwner = c.get('apiKeyOwner')
const key = `ratelimit:${keyOwner.id}:${Math.floor(Date.now() / 60000)}`
const count = parseInt(
(await c.env.RATE_LIMIT_KV.get(key)) || '0'
)
if (count >= keyOwner.rate_limit) {
c.header('Retry-After', '60')
return c.json({ error: 'Rate limit exceeded' }, 429)
}
await c.env.RATE_LIMIT_KV.put(key, String(count + 1), {
expirationTtl: 120,
})
c.header('X-RateLimit-Limit', String(keyOwner.rate_limit))
c.header('X-RateLimit-Remaining', String(keyOwner.rate_limit - count - 1))
await next()
}5. Key 轮转
5.1 为什么需要轮转
- Key 可能已经泄露但不知道
- 定期轮转是安全合规的要求
- 员工离职,需要更换共用的 Key
5.2 平滑轮转策略
直接更换 Key 会导致所有使用该 Key 的服务立即中断。正确做法:
- 生成新 Key
- 新旧 Key 同时有效(过渡期)
- 通知所有使用方切换到新 Key
- 过渡期结束后停用旧 Key
// D1 schema:支持每个 Key 记录多个有效 hash
CREATE TABLE api_keys (
id INTEGER PRIMARY KEY,
key_prefix TEXT NOT NULL,
key_hash TEXT NOT NULL,
is_primary INTEGER DEFAULT 0, -- 1 = 当前主 Key,0 = 过渡期旧 Key
expires_at TEXT,
is_active INTEGER DEFAULT 1
);
// 验证时接受新旧两个 Key
async function validateKey(key: string): Promise<{ valid: boolean; isPrimary: boolean }> {
const prefix = key.slice(0, 8)
const records = await db.prepare(
'SELECT * FROM api_keys WHERE key_prefix = ? AND is_active = 1'
).bind(prefix).all()
const hash = await hashAPIKey(key)
for (const record of records.results) {
if (hash === record.key_hash) {
return { valid: true, isPrimary: record.is_primary === 1 }
}
}
return { valid: false, isPrimary: false }
}
// 轮转流程
async function rotateKey(keyId: number): Promise<string> {
// 1. 生成新 Key
const newKey = generateAPIKey()
const newHash = await hashAPIKey(newKey)
// 2. 旧 Key 降级为过渡
await db.prepare(
'UPDATE api_keys SET is_primary = 0, expires_at = ? WHERE id = ?'
).bind(
new Date(Date.now() + 30 * 86400000).toISOString(), // 30 天过渡期
keyId
).run()
// 3. 插入新 Key
await db.prepare(
'INSERT INTO api_keys (key_prefix, key_hash, is_primary) VALUES (?, ?, 1)'
).bind(newKey.slice(0, 8), newHash).run()
// 4. 返回新 Key(只在创建时返回一次)
return newKey
}5.3 自动过期
给 Key 设置过期时间,强制定期轮转:
// 每次验证时检查过期
if (record.expires_at && new Date(record.expires_at) < new Date()) {
return c.json({ error: 'API Key expired' }, 401)
}6. AI 服务 Key 的特殊考量
AI 应用的 Key 管理有一些特殊点:
6.1 多模型多 Key
一个 AI 应用可能用多个模型提供商,每个都有 Key:
interface Env {
// AI 模型 Key
OPENAI_API_KEY: string
ANTHROPIC_API_KEY: string
COHERE_API_KEY: string
// 向量数据库 Key
PINECONE_API_KEY: string
// 监控 Key
SENTRY_DSN: string
}每个 Key 都是 Secrets,加密存储。
6.2 Key 使用量监控
AI 调用通常按 token 计费。需要监控每个 Key 的使用量:
// AI 调用中间件:记录 token 使用
async function aiUsageTracking(c: Context, next: Next) {
await next()
const usage = c.get('aiUsage') // 业务代码里设置的 token 使用量
if (usage) {
const userId = c.get('user')?.id || 'anonymous'
const date = new Date().toISOString().slice(0, 10)
await c.env.DB.prepare(
`INSERT INTO ai_usage (user_id, date, tokens, model)
VALUES (?, ?, ?, ?)`
).bind(userId, date, usage.tokens, usage.model).run()
}
}6.3 Key 成本预算
设置每个 Key 的月度预算上限:
async function checkAIBudget(c: Context, next: Next) {
const month = new Date().toISOString().slice(0, 7) // "2026-06"
const usage = await c.env.DB.prepare(
'SELECT SUM(cost) as total FROM ai_usage WHERE month = ?'
).bind(month).first()
const MONTHLY_BUDGET = 100 // 100 美元
if ((usage?.total || 0) >= MONTHLY_BUDGET) {
return c.json({ error: 'Monthly AI budget exceeded' }, 402)
}
await next()
}7. API Key 安全检查清单
| 检查项 | 说明 |
|---|---|
| Key 存在 Secrets / 环境变量 | 不硬编码在代码里 |
.gitignore 包含 .env | 防止环境文件提交到 Git |
| pre-commit hook 检测密钥 | gitleaks / git-secrets |
| 前端不持有服务端 Key | 前端只调自己的后端 |
| 日志不打印完整 Key | 只打印前 8 位 |
| Key 不在 URL 参数里 | 用 Header 传 Key |
| 错误响应不包含 Key | 通用错误信息,详情写日志 |
| 定期轮转 | 新旧 Key 同时有效平滑过渡 |
| 按服务独立分配 | 不同服务用不同 Key,泄露只影响一个 |
| 权限控制 | 每个 Key 只给必要的权限 |
| 使用量监控 | 异常使用及时发现 |
| 预算上限 | 防止 Key 泄露后产生高额费用 |
8. 小结
API Key 泄露是最常见的安全事故。泄露途径:代码仓库、前端代码、日志、URL 参数、错误响应。
保护原则:Key 只存服务端 Secrets(Cloudflare Workers 加密存储)、前端永远不持有、按服务独立分配、日志只打印前几位。
给第三方发的 Key 需要权限控制(读写分离)、限流(每个 Key 独立配额)、平滑轮转(新旧 Key 同时有效过渡)。
AI 服务 Key 的特殊性:多模型多 Key、token 使用量监控、成本预算上限。
下一篇讲环境变量安全——比 API Key 更底层,环境变量是所有密钥的载体。