去年九月我们公司被一次 JWT 密钥泄露事故狠狠教训了一次:攻击者通过一个被遗弃的开源测试项目里硬编码的 JWT secret,伪造了管理员 token,直接读取了我们生产环境的部分用户数据。事故影响了大约一万两千个账号的基础资料(没涉及密码和支付),根据 GDPR 我们必须在 72 小时内向监管报备并通知受影响用户,公关压力巨大,直接经济损失加上后续合规整改超过六十万人民币。这篇把这次事故的完整时间线、根因分析、修复方案、以及后来建立的"密钥治理体系"全部写一遍,希望能帮到正在用 JWT 做认证的团队避免同样的坑。
事故现场
背景是我们的一个 SaaS 产品,后端 Node.js + Express,认证用 JWT(HS256 对称签名),secret 在环境变量里管理,看起来一切正常。事故发现是在一个周一早上,SRE 同学例行查看 Sentry 告警,看到一批"权限校验异常"的告警,IP 来自一个奇怪的地址段,深入排查发现这个 IP 在过去 48 小时里用了上百个不同的用户 ID 调用了我们的内部 API,而且都通过了 JWT 校验。
| 时刻 | 事件 |
|---|---|
| 周六 23:14 | 攻击者首次出现, 用伪造 token 探测 API |
| 周日 03:22 | 攻击者开始批量拉取用户数据 |
| 周一 09:48 | SRE 发现 Sentry 异常告警 |
| 周一 10:30 | 定位是 JWT 校验通过但 IP 异常 |
| 周一 11:15 | 紧急轮换 JWT secret, 所有 token 失效 |
| 周一 14:00 | 找到泄露源: 开源测试项目硬编码 secret |
| 周一 18:00 | 评估影响范围: 12000 账号资料被读取 |
| 周三 16:00 | 72 小时内向监管报备完成 |
| 周四 起 | 启动"密钥治理体系"专项 |
泄露源头的离奇过程
追查 secret 泄露源的过程非常戏剧化。我们最初以为是某个员工电脑被入侵,或者 CI/CD 系统的 secret 被偷,排查了一圈都没结果。后来一个工程师灵机一动,用泄露的 secret 在 GitHub 上做了全文搜索,居然搜到了!原来是两年前一个实习生为了做毕业设计,基于我们的开源 SDK 写了一个 demo 项目,把测试环境的 JWT secret 当成默认配置写进了代码,推到了他个人的 GitHub repo。这个项目早就被废弃了,实习生也早就毕业离职了,但 repo 一直公开存在。
更要命的是,当时测试环境和生产环境用了同一个 JWT secret(典型的"为了省事"),而且这个 secret 上线后从来没轮换过。攻击者可能是通过自动扫描 GitHub 的工具发现的,或者是看到了 SDK 的源码追溯找到的,具体不可考,但攻击路径非常清晰:GitHub 公开 repo → 拿到 secret → 伪造任意用户的 JWT → 调用我们的 API → 拉走数据。整条链路里有四个独立的漏洞,任何一个堵住都不会出事,但四个全开,惨案就发生了。
第一时间的应急响应
发现攻击之后,我们的应急流程分了几步走。第一步是紧急轮换 JWT secret,所有现存的 token 立刻失效,所有用户被迫重新登录。这一步必须毫不犹豫,虽然会给正常用户带来"突然被踢"的体验,但相比数据继续泄露,这点不便完全可以接受。轮换之后攻击者的伪造 token 全部失效,新一波探测请求立刻被拦截。
// 紧急轮换 JWT secret 的 Node.js 代码
// 关键: 必须保证新旧 secret 短暂并存, 避免误伤正在登录中的请求
const jwt = require('jsonwebtoken');
const SECRETS = {
current: process.env.JWT_SECRET_NEW, // 新 secret
previous: process.env.JWT_SECRET_OLD, // 旧 secret, 5 分钟后清除
};
function verifyToken(token) {
// 先用新 secret 验证
try {
return jwt.verify(token, SECRETS.current, { algorithms: ['HS256'] });
} catch (e) {
// 5 分钟过渡期内, 旧 secret 也接受, 之后彻底废弃
if (SECRETS.previous && Date.now() < ROTATION_DEADLINE) {
try {
const payload = jwt.verify(token, SECRETS.previous, { algorithms: ['HS256'] });
// 旧 token 立刻刷新成新 secret 签发的版本
const newToken = jwt.sign(payload, SECRETS.current, { expiresIn: '1h' });
return { ...payload, _refresh: newToken };
} catch (err) {
throw new Error('Invalid token');
}
}
throw new Error('Invalid token');
}
}
第二步是把攻击 IP 全部封禁,在 WAF 层面拒绝所有来自这个 IP 段的请求,防止它继续尝试。第三步是评估数据泄露范围,从访问日志里梳理出攻击者读取过的所有用户 ID,按 GDPR 的要求准备通知材料。第四步是启动法务和公关流程,72 小时内必须向监管报备,这是硬性要求,违反会有重罚。
根因分析:四个独立漏洞
事故复盘时我们把根因梳理出了四条独立链路,每一条都是独立的安全漏洞。第一条是secret 硬编码到源码,这是最直接的原因。第二条是测试和生产共用 secret,即使测试 secret 泄露,生产环境也不应该受影响。第三条是secret 长期不轮换,从上线第一天到事故发生跨度三年多,secret 从来没换过。第四条是缺乏 GitHub 全网监控,我们的 secret 早就泄露在公开 repo 里,但我们一直不知道,如果有自动扫描机制,事故可以提前两年发现。
修复方案一:secret 管理彻底重构
事故之后我们做的第一件事是把所有秘密信息从代码和 .env 文件里彻底剥离,迁移到专业的 secrets manager。我们选了 HashiCorp Vault,部署在内网,所有应用通过短期 token 拉取所需的 secret。Vault 提供了几个关键能力:动态生成 secret、自动轮换、访问审计、按角色细粒度授权,这些都是 .env 文件无法提供的。
// 迁移到 Vault 后的 secret 加载逻辑
const vault = require('node-vault')({
endpoint: process.env.VAULT_ADDR,
token: process.env.VAULT_TOKEN // 短期 token, 通过 K8s service account 获取
});
let cachedSecrets = null;
let cacheExpiry = 0;
async function getSecrets() {
const now = Date.now();
if (cachedSecrets && now < cacheExpiry) {
return cachedSecrets;
}
const resp = await vault.read('secret/data/myapp/prod');
cachedSecrets = resp.data.data;
// 5 分钟缓存, 之后强制刷新, 拿到最新 secret
cacheExpiry = now + 5 * 60 * 1000;
return cachedSecrets;
}
async function getJwtSecret() {
const secrets = await getSecrets();
return secrets.jwt_secret;
}
Vault 的关键好处是自动轮换。我们配置了 JWT secret 每七天自动轮换一次,Vault 会同时保留新旧两个版本,应用通过监听 webhook 感知到轮换事件后,无缝切换。这种"密钥即代码,变更即流程"的管理方式比手工管理 .env 安全得多。即使某个 secret 不小心泄露,七天后也会自动失效,攻击窗口大幅缩小。
修复方案二:JWT 算法和结构升级
除了 secret 管理,我们也重新审视了 JWT 的使用方式。HS256(对称签名)有个天生的弱点:只要 secret 泄露,任何人都能签发任意 token。改用 RS256(非对称签名)之后,签名密钥和验证密钥分离,签名密钥只在认证服务里持有,其他服务只持有公钥验证。即使公钥泄露也无所谓,因为公钥本来就是公开的,攻击者拿到也不能伪造 token。
// 切换到 RS256 之后的签发和验证
const fs = require('fs');
const jwt = require('jsonwebtoken');
// 认证服务: 用私钥签发
const privateKey = fs.readFileSync('/secrets/jwt-private.pem');
function signToken(payload) {
return jwt.sign(payload, privateKey, {
algorithm: 'RS256',
expiresIn: '15m', // 短期 token, 配合 refresh token 机制
issuer: 'auth.example.com',
audience: 'api.example.com'
});
}
// 业务服务: 用公钥验证
const publicKey = fs.readFileSync('/secrets/jwt-public.pem');
function verifyToken(token) {
return jwt.verify(token, publicKey, {
algorithms: ['RS256'], // 严格限定算法, 防 alg=none 攻击
issuer: 'auth.example.com',
audience: 'api.example.com',
maxAge: '15m'
});
}
这里有几个细节值得专门强调。第一,verify 时必须显式指定 algorithms 参数,否则 jsonwebtoken 库会接受 token header 里声明的任意算法,包括 alg=none(允许无签名 token)和 alg=HS256(用公钥当 HMAC 密钥,这是 JWT 历史上最著名的漏洞)。第二,必须验证 issuer 和 audience,防止其他服务签发的 token 被误用。第三,maxAge 是双重保险,即使 token 里的 exp 字段被改,maxAge 也会拒绝过期 token。第四,token 有效期要短,15 分钟是个比较保险的值,配合 refresh token 机制不影响用户体验。
修复方案三:多层防御机制
事故让我们意识到,光靠 JWT 校验是远远不够的,必须有多层防御。第一层是IP 信誉检查,接入威胁情报库,已知恶意 IP 直接拒绝。第二层是速率限制,单个用户的 API 调用速率有上限,超出立刻熔断,避免被批量拉数据。第三层是异常行为检测,正常用户的访问有明显模式(地理位置、设备、时段),突然变化会触发额外验证(短信验证码、二次确认)。
// 多层防御中间件
const rateLimit = require('express-rate-limit');
const ipBlocklist = require('./threat-intel');
// 第一层: IP 信誉
async function ipReputationCheck(req, res, next) {
const ip = req.ip;
const score = await ipBlocklist.getScore(ip);
if (score > 80) {
log_security('blocked_malicious_ip', { ip, score });
return res.status(403).json({ error: 'forbidden' });
}
next();
}
// 第二层: 速率限制 (按用户)
const userRateLimit = rateLimit({
windowMs: 60 * 1000,
max: 60,
keyGenerator: req => req.user?.id || req.ip,
handler: (req, res) => {
log_security('rate_limited', { user: req.user?.id, ip: req.ip });
res.status(429).json({ error: 'too_many_requests' });
}
});
// 第三层: 行为异常检测
async function anomalyCheck(req, res, next) {
const profile = await getUserBehaviorProfile(req.user.id);
const current = {
ip_country: req.geoip.country,
user_agent: req.headers['user-agent'],
hour_of_day: new Date().getHours()
};
const anomalyScore = calculateAnomaly(profile, current);
if (anomalyScore > 0.85) {
return res.status(401).json({
error: 'verification_required',
challenge: 'sms'
});
}
next();
}
app.use(ipReputationCheck, userRateLimit, anomalyCheck);
多层防御的核心思想是纵深防御,任何一层都不能保证百分百安全,但多层叠加可以让攻击成本指数级上升。攻击者绕过 JWT 校验后,还要面对 IP 信誉、速率限制、行为检测等多重关卡,每一关都增加被发现的风险。这种设计让"单点漏洞导致全盘失守"的可能性大幅降低。
建立 GitHub secret 扫描机制
事故的一个根本原因是 secret 泄露到 GitHub 我们却不知道,所以建立全网 secret 扫描是必须的。GitHub 自己提供了 secret scanning 服务,可以扫描公开 repo 里的常见 secret 格式,但只覆盖少数大厂的 secret 模式。我们需要的是自定义模式扫描,把公司所有 API key、token、签名密钥的特征注册进去,GitHub 一旦发现匹配就会通知我们。
# GitHub Advanced Security 的 secret-scanning 自定义规则
# .github/secret_scanning.yml
patterns:
- name: MyApp JWT Secret
pattern: 'myapp_jwt_[a-zA-Z0-9]{32,}'
description: "MyApp 内部 JWT 签名密钥, 一旦发现立即报警"
- name: MyApp API Key
pattern: 'mak_(live|test)_[a-zA-Z0-9]{40}'
description: "MyApp 对外 API key"
- name: MyApp DB Password
pattern: 'mydb_pwd_[a-zA-Z0-9!@#$%^&*]{16,}'
description: "MyApp 数据库密码"
除了 GitHub 自带的,我们还接入了第三方的 secret 扫描服务(比如 GitGuardian),它能扫描更多平台的 repo,包括 GitLab、Bitbucket 等,覆盖面更广。每周自动扫描一次全网,任何匹配立刻告警,响应时间从"两年才发现"压到"几小时内发现"。这种"先发现先处置"的能力是事故预防的关键。
本地开发的 secret 隔离
除了线上的 secret 治理,本地开发的 secret 管理也是重灾区。开发者本地经常会有 .env 文件、各种测试 token、临时 access key,这些东西如果不小心提交到 repo 会立刻泄露。我们的做法是把 .env 加入全局 .gitignore,并配合 pre-commit hook 强制扫描,任何看起来像 secret 的字符串都阻止提交。
# .git/hooks/pre-commit 或用 pre-commit 框架
#!/bin/bash
# 用 detect-secrets 扫描即将提交的内容
if ! detect-secrets-hook --baseline .secrets.baseline $(git diff --cached --name-only); then
echo "❌ Detected potential secrets in staged files"
echo "如果确认是误报, 用 detect-secrets audit 标记后再提交"
exit 1
fi
# 额外检查: 禁止提交常见的密钥文件
for file in $(git diff --cached --name-only); do
case "$file" in
*.pem|*.key|*.p12|*.pfx|.env|.env.local|*.kdbx)
echo "❌ Refusing to commit secret file: $file"
exit 1
;;
esac
done
detect-secrets 是 Yelp 开源的工具,可以识别几十种 secret 格式,包括 AWS key、JWT、SSH 密钥、各种 API token 等。配合 .secrets.baseline 文件可以管理已知的"看起来像 secret 但实际不是"的字符串,减少误报。这种 pre-commit 防线把 secret 泄露挡在最早阶段,代价小、效果大。我们推广这套工具半年,内部 secret 泄露事件从平均每月两起降到零。
四种 secret 治理方案对比
| 方案 | 安全级别 | 实施成本 | 适合场景 |
|---|---|---|---|
| .env 文件 + .gitignore | ★ | 极低 | 个人项目, 不推荐生产 |
| K8s Secret | ★★ | 低 | 中小型项目, 静态密钥 |
| HashiCorp Vault | ★★★★ | 中 | 中大型项目, 动态密钥, 自动轮换 |
| AWS/Azure KMS + Secret Manager | ★★★★★ | 高 | 云原生项目, 合规要求高 |
团队立的几条规矩
- 所有线上 secret 必须存在 Vault, 禁止任何形式的硬编码或 .env 文件。
- 测试环境和生产环境 secret 必须完全隔离, 不允许复用。
- JWT 必须用 RS256, 禁止 HS256 (除非有充分理由并经过安全评审)。
- JWT 短期化, access token 不超过 15 分钟, refresh token 不超过 7 天。
- 所有 verify 调用必须显式 algorithms / issuer / audience 参数。
- pre-commit 必须接 detect-secrets, 阻断 secret 提交。
- 接入 GitHub secret scanning + GitGuardian 双重扫描。
- 关键 secret 每 7 天自动轮换, 紧急情况下可手动立即轮换。
- 任何安全事件 1 小时内启动应急响应, 4 小时内完成根因分析。
- 每季度做一次红蓝对抗演练, 检验防御体系有效性。
refresh token 机制的细节
JWT 短期化(15 分钟)带来的体验问题是用户每 15 分钟就要重新登录,显然不可接受。标准解决方案是access token + refresh token 双 token 机制:access token 短期(15 分钟),用来调 API;refresh token 长期(7 天或 30 天),只用来换新的 access token。这样既保证了安全(access token 被截获影响窗口短),又保证了体验(refresh token 期间用户无感)。
// refresh token 实现
async function refreshAccessToken(refreshToken) {
// 1. 验证 refresh token 签名和过期
let payload;
try {
payload = jwt.verify(refreshToken, publicKey, { algorithms: ['RS256'] });
} catch (e) {
throw new Error('Invalid refresh token');
}
// 2. 检查 refresh token 是否被吊销 (存在 Redis)
const revoked = await redis.get(`revoked_refresh:${payload.jti}`);
if (revoked) {
// 关键: 如果用过的 refresh token 又被用了, 可能是攻击者拿到了 token
// 立刻吊销该用户所有 token
await revokeAllUserTokens(payload.sub);
throw new Error('Refresh token reuse detected, all tokens revoked');
}
// 3. refresh token 一次性使用, 用过即作废 (rotation)
await redis.setex(`revoked_refresh:${payload.jti}`, 7 * 24 * 3600, '1');
// 4. 签发新的 access + refresh token
const newAccess = jwt.sign({ sub: payload.sub }, privateKey,
{ algorithm: 'RS256', expiresIn: '15m' });
const newRefresh = jwt.sign({ sub: payload.sub, jti: uuid() }, privateKey,
{ algorithm: 'RS256', expiresIn: '7d' });
return { accessToken: newAccess, refreshToken: newRefresh };
}
refresh token 机制的精髓在"一次性使用 + 滥用检测"。每个 refresh token 只能用一次,用完立刻作废并签发新的。如果某个已被使用的 refresh token 又被用了,几乎可以确定有人拿到了旧 token 想要冒用,这时候立刻吊销该用户的所有 token,强制重新登录。这种设计能在攻击者拿到 refresh token 的极短时间内发现异常并止损,是 JWT 安全实践里的关键一环。
事故损失和合规成本
| 项目 | 金额(人民币) | 说明 |
|---|---|---|
| 紧急响应人力 | 5 万 | 3 天 12 人加班排查 |
| GDPR 用户通知 | 8 万 | 邮件 + 短信 + 公告 |
| 法务咨询 | 15 万 | 合规律师团队 |
| 安全审计 | 20 万 | 第三方渗透测试 |
| Vault 部署 | 3 万 | HA 集群 + 培训 |
| 用户安抚补偿 | 10 万 | 受影响用户的优惠券 |
| 合计 | 61 万 | 不含品牌信任损失 |
这次事故的直接经济损失就六十多万,如果再加上品牌信任受损、用户流失、内部士气打击等隐性成本,总损失可能上百万。最贵的不是修复成本,而是合规和公关。GDPR 罚款理论上可以到全球年收入的百分之四,虽然我们这次因为响应及时只收到了警告没罚款,但这个达摩克利斯之剑一直悬在头上。在欧盟做生意的公司,任何用户数据泄露都是天价代价。
给同行的几条建议
第一条建议是secret 是负债,不是资产。每多一个 secret,就多一份泄露风险;每延长一天 secret 寿命,泄露概率就累加一份。理想状态是没有长期 secret,所有 secret 都是动态生成、短期有效、用过即焚。Vault、AWS Secret Manager、Azure Key Vault 这类工具就是为这个目标设计的,值得投入。
第二条建议是JWT 不是万能药。JWT 的优势是无状态、易扩展,但代价是吊销困难、payload 容易泄露、签名错误代价惨重。很多场景下用 session(存 Redis)比 JWT 更安全,只是不那么"现代"。选 JWT 之前先问自己:你真的需要无状态吗?分布式架构不一定要无状态认证。
第三条建议是安全是个体系,不是点状修复。一次事故修一个点,下次还有别的点暴露。正确的做法是建立完整的安全治理体系,包括 secret 管理、扫描机制、审计流程、应急预案、红蓝对抗等等。这些都是基础建设,前期投入大但长期收益巨大。
第四条建议是把安全前置到开发流程。pre-commit 扫描、code review 安全检查、SAST/DAST 工具、依赖扫描,这些都应该集成到 CI/CD,让安全在最早阶段被发现。事故之后再补救代价是事前预防的几十倍,这个账要算清楚。
第三方 SDK 和供应链安全
这次事故的另一个反思是对开源 SDK 的安全意识不足。我们的 SDK 在 GitHub 开源,文档里有"测试用 secret"的示例,实习生直接复制了示例,以为是无害的占位符。事后我们把所有示例 secret 都换成明显假的字符串("REPLACE_WITH_YOUR_SECRET"),并在 README 醒目位置加了安全警告,降低后来者犯同样错误的概率。
更广泛地,软件供应链安全是个独立的话题。npm 包被攻击者插入恶意代码、PyPI 包名劫持、依赖更新引入漏洞,这些威胁每天都在发生。我们后来在 CI 里加了 Snyk 和 npm audit,任何依赖出现高危漏洞都自动告警,严重的直接阻断构建。这种"持续扫描依赖"的做法虽然麻烦,但能挡住很多供应链攻击,值得每个团队都做。
# CI 集成依赖扫描的示例 (GitHub Actions)
name: Security Scan
on: [push, pull_request]
jobs:
audit:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 20
- name: npm audit (high+)
run: npm audit --audit-level=high
- name: Snyk scan
uses: snyk/actions/node@master
env:
SNYK_TOKEN: ${{ secrets.SNYK_TOKEN }}
with:
args: --severity-threshold=high
- name: Detect secrets
run: |
pip install detect-secrets
detect-secrets scan --baseline .secrets.baseline
"零信任"思路的初步实践
这次事故让我们正式考虑引入零信任架构的部分思路。传统的安全模型是"边界防御",内网是可信的,外网是不可信的,但现在的攻击越来越多来自内部或穿越边界,边界防御已经不够。零信任的核心是"永不信任,始终验证":每个请求无论来源都要验证身份和权限,任何资源访问都要最小权限原则。
我们做了几个零信任方向的改造。第一,服务间通信全面 mTLS,每个服务有自己的证书,互相验证身份,即使内网被攻破单点也不能横向移动。第二,数据库访问从"应用 ID/密码"改成"应用证书 + 临时 token",通过 Vault 动态发放,过期自动失效。第三,管理后台所有操作要求二次验证(YubiKey 硬件密钥 + 短信),防止 session 被劫持。这些改造让攻击者即使突破第一道防线,后面还有重重关卡要过,大大提高了攻击成本。
事故复盘的几点反思
第一点反思是简单的错误造成最大的损失。事故根因不是什么高深的漏洞,就是"硬编码 secret"这种最基础的错误,但因为环境和时间的叠加变成了惨案。这提醒我们,安全的基本盘比花哨的高级技术更重要。基础没做好,任何高级防御都是花架子。
第二点反思是历史遗留的问题最危险。三年前的实习生项目,谁都不会再去关注,但它就这么存在着、危险着。我们事故后做了一次"历史代码审计",清理出几十个类似的隐患:废弃的测试 endpoint 没下线、过期的 API key 没回收、离职员工的账号没删等等。这种"考古"工作虽然枯燥但极其重要,每个公司都应该定期做。
第三点反思是事故响应能力比事故预防更关键。完全防止事故是不可能的,但能否在事故发生时快速发现、快速止血、快速恢复,决定了事故的影响范围。我们事故响应做得还算可以(发现到止血 1.5 小时),但还有提升空间。后来我们专门搞了 SOAR(Security Orchestration, Automation and Response)平台,自动化大部分应急流程,响应时间又压短了一半。
组织层面的安全文化建设
技术修复只是一半,另一半是组织文化的转变。事故之前,我们公司对安全的态度是"出问题再说",安全团队两个人,主要做被动响应。事故之后管理层认知到位,安全团队扩到十几人,每个业务线配安全 BP,定期做安全培训和演练。这种"安全融入每个团队"的模式比"集中的安全部门"效率高得多。
另一个变化是安全 review 强制化。任何涉及认证、授权、加密、数据存储的代码,必须经过安全团队的 review 才能合并。一开始大家抱怨"流程太慢",但几个月之后大家发现这种 review 真的能挡住很多潜在问题,反对声音消失了。安全 review 不是阻塞,而是保护,这种心态转变需要时间和事故的教育。
团队推广这些规矩时遇到的真实阻力
把上面那十条规矩推广到全公司时,我们遇到了不少现实阻力,记录下来给同行参考。第一个阻力来自历史项目的迁移成本。老项目里 .env 文件、硬编码 secret、HS256 JWT 比比皆是,要全部迁移到 Vault 加 RS256,工作量惊人。我们的解决方案是分级处理:核心生产系统三个月内必须完成,边缘系统半年内完成,实在迁不动的小工具允许保留但要加 secret 扫描。这种"分级强制"的方式比"一刀切"更可行,执行下来阻力小很多。
第二个阻力来自开发者对短期 token 的抱怨。15 分钟的 access token 意味着开发调试时频繁要重新登录,体验很差。我们的折中是开发环境的 access token 设长一些(2 小时),生产环境严格 15 分钟。这种"环境差异化"的配置让开发体验和生产安全都得到了平衡,大家也能接受。但要注意配置文件不要错混,我们出过一次 dev 配置被打包到 prod 的事故,后来加了构建时强制校验,杜绝了这种问题。
第三个阻力来自对 Vault 的运维成本担忧。Vault 高可用部署比较复杂,需要专门的运维投入,小团队可能扛不住。我们的实践是优先用云厂商托管服务,AWS Secrets Manager / Azure Key Vault / GCP Secret Manager 都是托管的,免运维,成本可控。只有当对自主可控有强需求时才自建 Vault。这种"先用托管再考虑自建"的路径让 secret 管理的门槛大幅降低,更多团队可以走上正轨。
第四个阻力来自跨团队的安全审计。每个业务线都觉得"安全是安全团队的事,我们写好功能就行",对安全审计抵触。我们的破局是把安全 KPI 写进每个团队的考核,安全事故按严重程度扣分,安全主动改进有加分。这种"利益绑定"的方式比单纯说教有效得多,几个季度下来,各团队的安全意识普遍提升,主动来找安全团队咨询的人也多了起来。
给创业团队的特别建议
事故反思之外,我想给创业团队特别说几句。安全在创业早期经常被牺牲掉,"先把功能做出来,安全后面再加",这种心态非常危险。早期决策(用什么认证方案、secret 怎么管、日志怎么打)的影响会延续多年,事后修补的代价远大于事前设计。建议创业团队从第一天就把基础安全做对:用 Vault 或 AWS Secret Manager,用 RS256 JWT,接 secret 扫描,这些都不复杂但能避免后续的大量麻烦。
另一个建议是合规要前置。GDPR、CCPA、个人信息保护法等等法规对数据处理有严格要求,如果到了用户量起来再补,改造代价巨大,可能要重写很多核心模块。早期就按合规要求设计数据存储和访问,虽然短期内多一些工作量,但长期受益。我们这次事故能在 72 小时内完成 GDPR 报备,得益于早期就建立了完整的用户数据访问日志,这种基础功夫平时看不见,关键时刻救命。
总结
这次 JWT 密钥泄露事故是我职业生涯里印象最深的一次安全事件,六十万的损失换来了团队对安全的全新认知。核心教训是secret 管理必须用专业工具、JWT 必须用 RS256 + 短期化、安全必须多层防御、必须有持续的扫描和监控。任何一个环节做好都能避免事故,但我们当时一个都没做好,代价惨痛。
希望这篇能给正在做认证系统的同行一些参考。如果你现在还在用硬编码 secret、HS256 长期 token、没有 secret 扫描、没有多层防御,请尽快补上,不要等到事故发生才后悔。安全工作是"投入十年看不到收益,但一次事故就看到了缺失"的事,需要长期主义。希望我们的教训能让更多团队少踩一些坑。
最后想说的是,做完这次安全建设之后,团队的整体工程能力也有了显著提升。安全的本质是"做事严谨",这种严谨会传染到代码质量、运维流程、设计决策的方方面面。一个安全做得好的团队,通常其他方面也都不会差。所以即使你没有立刻的安全压力,提前投资安全建设也是值得的,它会让你的整个工程体系都受益,长期回报远超短期成本。这次事故让我们成长了很多,也让团队凝聚力上了一个台阶,从某种意义上说是坏事变好事的典型案例。
—— 别看了 · 2026