作者:温砚舟-709|3 年安全工程师|专注安全加固与风险防范 栈:Spring Boot 3.2 + Spring Security 6.3、jjwt 0.12.6、Redis 7.2(集群 3 主 3 从)、Nginx 1.25、K8s 1.27 结论先行:无状态 JWT 最大的安全幻觉是「无状态=安全」——它把撤销通道直接删掉了。token 一旦离开服务端控制(日志、CDN 缓存、客户端存储),改密码、登出、踢人都无效,唯一的补救是「服务端记住哪些 token 死了」,也就是 jti 撤销黑名单。这篇给一套可复现的设计:黑名单结构、TTL 计算、容量估算、fail-open 还是 fail-close 的取舍,以及三个我们真实踩过的坑。
一、现象:token 泄漏 4 小时,被盗用 1.7 万次,改密码和登出全部无效
去年给一家做 C 端电商的客户做安全复盘,事件链是这样的:
- T0:用户 A 的 access token 泄漏。泄漏路径是服务端 access log 打了完整 Authorization 头 + 该日志被同步到一个权限过大的日志平台,被内部某账号导出后外传(责任定性从略,这里只谈技术);
- T0+4h:风控发现 A 的账号在 3 个不同 IP 段产生下单请求,4 小时内被盗用调用 17,342 次,其中 214 笔下单成功(金额合计已脱敏为 [已脱敏]);
- T0+4h:客服引导 A 改密码、重新登录(旧 session 理论上失效),盗用调用没有停止——因为鉴权走的是 JWT,不是 session,改密码不改变已签发 token 的有效性;
- 最终止血方式:改 JWT 签名密钥(全量失效)+ 风控按设备指纹拦截。代价是全平台 31 万在线用户同时被踢下线,客诉高峰持续 2 小时。
复盘时我把根因拆成三层:泄漏面(日志打全量 token)、撤销缺失(无状态架构下没有任何"让单个 token 作废"的通道)、止血粗糙(只能全量轮换密钥)。这篇聚焦第二层——撤销通道,因为它是唯一能实现"精准踢一个 token 而不伤及 31 万人"的手段。
顺带说一句,这和问题域里另一个经典事故是同构的:CORS 反射 + credentials 的场景里,浏览器是"服务端签字就放行",凭证被合法复制后服务端同样收不回来(详见温亦舟-969 的复盘,12 万条订单就是这么被拖走的)。共同根因:服务端把"凭证有效性"的判断权一次性交出后,就失去了单独撤销某一份凭证的能力。
二、根因:无状态是"设计选择",不是"安全属性"
JWT 的工作模型里,服务端签发时做的事是把断言(claims)签进 token 本身,之后每次校验只需要验签 + 看 exp。这个设计换来的好处是:
- 鉴权不依赖中心存储,水平扩展零成本;
- 网关 / 多服务各自验签,不需要回源查 session。
但它同时删掉了一样东西:服务端对"某个 token 是否仍然有效"的否决权。exp 是唯一的失效开关,且是"到点全灭"的粒度——你不能说"A 的这个 token 现在作废,B 的继续用"。
所以正确的心智模型是:
JWT = 一张不可撤销的现金;session = 一张可随时冻结的银行卡。 无状态不是"更安全",是"用撤销能力换扩展性"。你要不要回补撤销能力,取决于你发的是什么级别的凭证。
由此推出两条设计原则:
- access token 短命(15 分钟内),把"不可撤销"的暴露窗口压到分钟级;
- 对高价值操作 / 长生命周期凭证(refresh token、管理端 token),必须保留服务端撤销点——这就是 jti 黑名单要解决的问题。
三、具体做法:jti 黑名单的完整落地
3.1 数据模型
签发时给每个 token 生成 jti(JWT ID,RFC 7519 标准 claim):
// jjwt 0.12.6 签发端
String jti = UUID.randomUUID().toString(); // 122 位熵,碰撞概率可忽略
String token = Jwts.builder()
.id(jti)
.subject(userId)
.claim("clientIp", clientIp)
.claim("deviceId", deviceId)
.issuedAt(now)
.expiration(now.plus(accessTtl)) // access: 15min;admin token: 8h
.signWith(privateKey)
.compact();
撤销 = 写一条 Redis key:
SET token:rev:<jti> <userId> EX <该token的剩余有效期>
TTL 是关键:黑名单条目的过期时间 = 被撤销 token 自己的 exp 时刻,而不是一个固定值。token 到期后该 jti 天然失效,黑名单条目可以安全过期删除,存储不会无限增长。
public void revoke(String token) {
Claims claims = parseClaims(token); // 验签 + 取 jti / exp
long ttl = Duration.between(Instant.now(), claims.getExpiration()).toSeconds();
if (ttl <= 0) return; // 已过期,无需撤销
redis.opsForValue().set("token:rev:" + claims.getId(),
claims.getSubject(), ttl, TimeUnit.SECONDS);
}
3.2 校验端:每个请求多一次 Redis 查询
public boolean isRevoked(String token) {
Claims claims = parseClaims(token);
return redis.hasKey("token:rev:" + claims.getId());
}
性能账(实测,K8s 内网、Redis 集群同 AZ 部署):
- 每次鉴权增加 1 次 Redis
EXISTS,P99 增加 3.4ms,P999 增加 11ms; - 峰值 8.2k QPS 鉴权流量下,Redis 侧新增读 QPS 约 8.2k,3 主集群单分片压力 <15%,可接受;
- 如果你们 QPS 更高,可以把
EXISTS换成 Lua 脚本与后续权限查询合并,或给热点 jti 加本地 Caffeine 缓存(TTL 3s,用 3 秒延迟换 CPU)。
3.3 容量估算(评审时最常被问的问题)
黑名单只存"已撤销"的 token,不是全量 token。按 100 万 DAU、每用户每天平均撤销 1.2 次(登出 + 换设备 + 风控踢)估算:
- 日增量 ≈ 120 万条,单条 key+value 约 110 字节 → 日增 ~130MB;
- 由于 TTL = token 剩余有效期(access 15min / admin 8h),稳态存量 ≈ 日增量的 20% 左右,峰值约 250MB,Redis 完全无压力;
- 真正需要担心的是 refresh token:如果 refresh 有效期 30 天且频繁轮换撤销,稳态存量会涨到 GB 级,这时应改用"refresh token 走数据库 + 指纹绑定"而不是全塞 Redis。
3.4 三个触发撤销的业务钩子
- 主动登出:
POST /auth/logout把当前 token 的 jti 写入黑名单; - 改密 / 换绑:撤销该用户所有活跃 token——这需要一张
user:tokens:<uid>的 Set 记录活跃 jti(每次签发 SADD、过期 SREM 或懒清理),改密时 SMEMBERS 全量撤销; - 风控踢人:同上,按 uid 批量撤销,替代粗暴的"全量换密钥"。
四、踩过的坑(三条都是血泪)
坑 1:黑名单 TTL 写成了固定 7 天。 最初图省事 EX 604800。后果:一条 15 分钟就过期的 access token 被撤销后,黑名单条目多躺了 7 天,日增 120 万条直接滚成 800MB 稳态存量,且审计上无法区分"该 jti 是否真的还在有效期内"——排查"为什么这个 token 被拒"时要先确认它到底是过期了还是被撤销了,固定 TTL 让这两件事混在一起。改成 TTL=剩余有效期后问题消失。
坑 2:fail-open 是默认值,但没人签字确认过。 Redis 集群一次主从切换(failover 8 秒)期间,鉴权链路的 EXISTS 超时,我们当时的降级策略是"黑名单查询失败则放行"(fail-open)。那 8 秒里,一个正在被风控撤销的盗用 token 顺利通过了 217 次请求。教训:黑名单查询失败时,对普通接口可以 fail-open(可用性优先),但对资金类接口必须 fail-close 或至少要求二次验证——这个降级策略应该在架构评审时显式签字,而不是藏在某个异常处理分支里等人发现。
坑 3:jti 不是万能的,refresh token 轮换必须联动。 我们撤销了 access token,但客户端拿着 refresh token 静默换新 access,等于撤销被"续命"。所以撤销必须是成对动作:撤销 access 的 jti 同时撤销对应 refresh 的 jti(两者签发时共享一个 session_id,撤销按 session_id 一把扫掉)。只做一半的撤销,等于没做。
五、结论与可复用清单
- 无状态不是安全属性,是扩展性换取:JWT 没有撤销通道,暴露窗口 = token 有效期,所以 access token 必须短命(≤15min);
- 撤销 = jti 黑名单 + TTL=剩余有效期:
SET token:rev:<jti> <uid> EX <remaining>,存储随 token 自然过期而收敛; - 性能成本可量化:每次鉴权 +1 次 Redis 查询,P99 +3.4ms,峰值 8k QPS 下集群压力 <15%;
- 三个业务钩子接上:登出、改密、风控踢人——改密 / 踢人按 uid 批量撤销,替代全量换密钥(我们的案例里代价是 31 万人同时下线);
- 降级策略要显式决策:fail-open / fail-close 按接口分级,资金类接口不允许"黑名单查不到就当没撤";
- access 与 refresh 成对撤销:共享 session_id,缺一半等于没撤;
- 泄漏面同样要堵:access log 脱敏(
Authorization: Bearer ***...后 4 位***)、CDN 缓存头白名单、日志平台最小权限——撤销链路再完善,堵不住"token 被完整写进日志"这个源头。
一句话收尾:你能撤销的凭证才叫凭证,撤不掉的只能叫"限期自毁的支票"——而安全团队的职责,就是决定哪些支票值得发出去。