1. 从一个凌晨三点的告警说起:JWT到底解决了谁的痛
那是几年前的一个电商大促前夜,我们的订单服务在扩容到第六个实例之后,用户开始零星反馈"刚登录就被踢下线"。排查到天亮才发现,认证服务还在用单机内存里的Session存登录态,新扩容的实例压根不认识老实例发的会话ID。那次事故之后,我们把认证方案整体换成了JWT,也踩了后面我会一个个讲到的坑。
JWT,全称JSON Web Token,翻译过来就是"JSON格式的Web令牌"。它本质上是一串用点号分隔的字符串,长得像xxxxx.yyyyy.zzzzz这样。服务端用它来证明"这个请求确实是某个已登录用户发来的",而关键在于——验证这个过程不需要查数据库、不需要共享内存,只要双方约定好一个密钥,任何一台机器都能独立完成校验。这一点决定了它在多实例部署、跨服务调用、前后端分离这些场景里的天然优势。
这篇文章适合三类人看:正在做前后端分离项目、纠结登录态怎么存的后端同学;被"token过期了怎么办"折磨过的全栈同学;以及想搞清楚JWT到底安不安全、漏洞出在哪的架构同学。我会从结构拆解、密钥与算法选型、签发校验的完整代码、续签方案对比、防篡改加固、SPA落地这六个角度,把我这些年踩过的坑和验证过的方案都摊开讲。全程给可运行的代码,参数取值也给理由,不做"你先这么配就行"这种糊弄。
先说一个反直觉的结论:JWT本身不是登录方案,它只是一种"带签名的数据载体"。很多人把它当成Session的替代品直接套用,结果做出一个既不能主动踢人、又不能防止重放的"伪登录系统"。理解它的能力边界,比会调它的API重要得多。
2. 拆开那三段字符串:结构、签名与三个易混概念
2.1 Header、Payload、Signature各自装了什么
JWT的三个部分,用点号隔开。第一部分是Header,第二部分是Payload,第三部分是Signature。
Header是个JSON,描述这个token用什么算法签的,典型长这样:
{ "alg": "HS256", "typ": "JWT" }Payload也是JSON,装的是你要传递的业务数据,官方管里面的字段叫"声明"(claim)。声明分三种:预定义声明(比如iss签发者、exp过期时间、sub主体、iat签发时间)、公共声明(自己定义但语义通用的,比如role)、私有声明(业务专用的,比如userId、tenantId)。
Signature是把前两段Base64URL编码后的字符串拼起来,加上密钥,用指定算法算出来的。以HS256为例,公式大致是:
HMACSHA256(base64UrlEncode(header) + "." + base64UrlEncode(payload), secret)这里有个新手最容易犯的误解:Header和Payload只是Base64URL编码,不是加密。Base64URL做的是"把二进制转成URL安全的字符",任何人都能解码还原出原文。所以Payload里塞手机号、身份证、密码这类敏感信息,等于当街喊出来。
2.2 编码、签名、加密,三件事别混为一谈
我见过不少同学把"JWT是加密的"当成常识,面试时也这么说,这就危险了。
- 编码(Encoding):Base64URL 属于编码,目的是让数据能安全地放进URL和HTTP头,谁都能解,没有任何保密性。
- 签名(Signing):用密钥和算法生成Signature,目的是防篡改——谁改了Payload,签名就对不上。但它不隐藏内容。
- 加密(Encryption):JWE(JSON Web Encryption)才涉及加密,能把Payload真正隐藏起来。日常业务里用JWS(就是普通JWT)的场景占绝大多数,用JWE的很少。
所以一个准确的表述是:标准JWT保证的是完整性和来源可信,不保证机密性。要机密性,要么别往Payload里放敏感数据,要么上JWE,要么自己再对敏感字段做一层对称加密。
2.3 签名到底怎么防篡改:一次手工验算
光说原理容易虚,我们手工算一遍,你就能彻底记住。
假设Header是{"alg":"HS256","typ":"JWT"},Payload是{"userId":1,"exp":1735660800}。
第一步,各自Base64URL编码:
header -> eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9 payload -> eyJ1c2VySWQiOjEsImV4cCI6MTczNTY2MDgwMH0第二步,拼成header.payload,用HMAC-SHA256加密钥算出签名,再Base64URL编码:
Signature -> 3Xk9... (一段44字符左右的串)最终token就是三段拼起来。现在攻击者把Payload改成{"userId":999,...},想把自己伪造成999号用户。他一改,第二段变了,但他没有密钥,算不出对应的新签名。服务端拿着新前两段和旧签名一对,HMAC结果不匹配,直接拒绝。
注意:整个防篡改能力完全押在密钥保密上。密钥一旦泄露,攻击者可以伪造任意用户的token。这就是为什么后面要专门讲密钥管理。
从这个手工过程能看出一个关键点:服务端校验时必须用原始字符串重新算签名,不能先用库把token解析成对象、再自己拼回去算。因为解析再序列化,字段顺序、空格、转义都可能变,签名必然对不上。这是排查"签名校验莫名失败"时的经典陷阱,后面第7节会再展开。
3. 密钥、算法、过期时间:配置里最容易埋雷的三处
3.1 HS256还是RS256:对称与非对称的取舍
选算法是JWT配置的第一个岔路口。
HS256是对称算法,签发和校验用同一个密钥。特点是快、实现简单,适合单体应用或者签发方和校验方是同一套系统的场景。
RS256是非对称算法,私钥签发、公钥校验。特点是校验方拿不到私钥也能验签,适合多服务共用认证的场景——认证中心持私钥签发,其他业务服务只拿公钥校验,即使某个业务服务被攻破,也伪造不出token。
我把两者的对比列清楚,方便你拍板:
| 维度 | HS256 | RS256 |
|---|---|---|
| 密钥类型 | 对称,单密钥 | 非对称,公私钥对 |
| 签发速度 | 快 | 慢一些(约慢数倍) |
| 校验速度 | 快 | 慢一些 |
| 密钥分发风险 | 高,校验方也拿到签发密钥 | 低,校验方只拿公钥 |
| 密钥长度 | 建议≥32字节 | RSA建议2048位以上 |
| 适用场景 | 单体、内部服务 | 多服务、第三方对接、开放平台 |
我的经验是:只要校验方不止一个,或者校验方不完全可信,就上RS256。别为了那点性能在安全上偷懒。如果确实用了HS256,密钥长度必须够——HS256的密钥建议至少256位(32字节),别用"secret123"这种。判断标准很简单:密钥的熵要远超暴力破解的成本。
3.2 Payload里什么该放、什么绝对别放
Payload的体积也要控制。因为token要在每个请求的Header里带上,太大就会白白吃掉带宽——HTTP头通常有8KB限制,JWT放在里面,超了直接报错。
该放的:用户ID、角色、租户ID、会话标识(jti)、过期时间。这些字段体积小、校验时常用。
不该放的:
- 密码、密码哈希:一旦泄露直接完蛋。
- 手机号、身份证、银行卡:Base64能解,等于明文。
- 特别大的权限列表:塞几百个权限码进去,token体积失控,而且权限一改旧token就过期了,反而更麻烦。
- 频繁变动的状态:JWT是无状态的,改了Payload里的数据,已签发的旧token不会自动更新。放进来的动态数据只会让你更头疼。
有个折中做法:Payload里只放userId和role这类"变化不频繁"的字段,权限细粒度控制放到服务端缓存里查。这样token小,权限调整也能生效。代价是校验时多一次缓存查询,但对绝大多数业务来说这点开销完全可以接受。
3.3 密钥管理:把secret从代码里赶出去
我见过最离谱的一次,是把JWT密钥硬编码在Git仓库的config.js里,仓库还是公开的。那套系统的token等于任何人都能伪造。
正确做法:
- 密钥放在环境变量或配置中心,不进代码仓库。用
.env也要确保.env在.gitignore里。 - 不同环境用不同密钥。开发、测试、生产的密钥必须隔离,绝不能让开发密钥能签发生产token。
- 支持密钥轮换。密钥不是设一次就永远不变的,要有轮换机制。JWT有个
kid(Key ID)字段,专门用来标识"这个token是用哪个密钥签的"。服务端维护一个kid -> 密钥的映射,轮换时新token用新kid,旧token仍能用旧密钥校验,平滑过渡。 - 定期审计。谁动过密钥,什么时候动的,要有记录。
给一个用kid的Header示例:
{ "alg": "RS256", "typ": "JWT", "kid": "2024-11-key-01" }校验时先读kid,从密钥库里取对应的公钥验签。轮换期可以配置成"新密钥已生效但旧密钥仍可验签30天",30天后旧token自然过期,旧密钥就能下线了。
4. 从零跑通签发与校验:Node与Java两套代码
4.1 环境准备与依赖选择
Node侧用jsonwebtoken(社区里用得最多,API简单)或jose(更现代,支持Promise和更多算法)。Java侧用jjwt(io.jsonwebtoken,集成方便)或 Nimbus JOSE + JWT。
安装:
npm install jsonwebtoken<!-- Java: jjwt --> <dependency> <groupId>io.jsonwebtoken</groupId> <artifactId>jjwt-api</artifactId> <version>0.12.5</version> </dependency> <dependency> <groupId>io.jsonwebtoken</groupId> <artifactId>jjwt-impl</artifactId> <version>0.12.5</version> <scope>runtime</scope> </dependency> <dependency> <groupId>io.jsonwebtoken</groupId> <artifactId>jjwt-jackson</artifactId> <version>0.12.5</version> <scope>runtime</scope> </dependency>为什么用jjwt而不是java-jwt?两者都能用,jjwt对Spring生态更友好,文档也更全,我在Spring项目里习惯用它。选型上其实差别不大,关键是别自己手写Base64和HMAC,除非你在学习。手写极容易在padding、字符集、Base64URL这些细节上出错。
4.2 签发token:把参数取值讲清楚
Node版本:
const jwt = require('jsonwebtoken'); function signToken(user, secret) { const payload = { userId: user.id, role: user.role, jti: require('crypto').randomUUID() }; return jwt.sign(payload, secret, { algorithm: 'HS256', expiresIn: '15m', issuer: 'auth.center', audience: 'web.api' }); }这里几个参数值得说:
expiresIn: '15m':有效期15分钟。取值逻辑是——access token要短,因为一旦泄露,攻击者能用的时间窗口就是这个有效期。我一般把access token设成15分钟到2小时,越敏感的系统越短。issuer和audience:签发者和受众。校验时一起验,能防止"这个token本来是A系统发的,被拿到B系统用"。跨系统场景必须配。jti:唯一标识,为后面主动踢人预留。
Java版本:
import io.jsonwebtoken.Jwts; import java.security.Key; import java.util.Date; public String signToken(Long userId, String role, Key key) { long now = System.currentTimeMillis(); return Jwts.builder() .subject(String.valueOf(userId)) .claim("role", role) .id(java.util.UUID.randomUUID().toString()) .issuer("auth.center") .audience().add("web.api").and() .issuedAt(new Date(now)) .expiration(new Date(now + 15 * 60 * 1000)) .signWith(key) .compact(); }4.3 校验:中间件里必须处理的异常分支
校验不是"对/错"两个结果,而是有一堆异常分支要分别处理。下面这个Node中间件把常见分支都覆盖了:
function authMiddleware(secret) { return (req, res, next) => { const header = req.headers.authorization || ''; if (!header.startsWith('Bearer ')) { return res.status(401).json({ code: 'NO_TOKEN' }); } const token = header.slice(7); try { const decoded = jwt.verify(token, secret, { algorithms: ['HS256'], issuer: 'auth.center', audience: 'web.api' }); req.user = decoded; next(); } catch (err) { if (err.name === 'TokenExpiredError') { return res.status(401).json({ code: 'TOKEN_EXPIRED' }); } if (err.name === 'JsonWebTokenError') { return res.status(401).json({ code: 'TOKEN_INVALID' }); } return res.status(401).json({ code: 'TOKEN_ERROR' }); } }; }两个细节我必须强调:
第一,algorithms: ['HS256']这个白名单是必须写的。如果不写,库会信任token Header里的alg字段,这就给了alg=none攻击的可乘之机,第5节展开讲。白名单等于把"用哪个算法"的决定权从攻击者手里夺回来。
第二,TokenExpiredError和JsonWebTokenError要分开返回。前者代表"token是真的但过期了",前端应该去走刷新流程;后者代表"token压根不合法",前端应该跳登录页。混在一起返回,前端没法区分,用户体验会很差——过期了却要重新登录,明明可以静默刷新。
4.4 过期时间、时钟偏移与nbf
exp(过期时间)和nbf(not before,生效时间)都是时间戳,单位秒。这里有两个坑:
一个是时钟偏移。分布式系统里各机器时间不完全同步,签发机器的exp到了校验机器可能还没到,或者校验机器觉得已经过期。解决方案是在校验时留一点容差,Node里叫clockTolerance:
jwt.verify(token, secret, { algorithms: ['HS256'], clockTolerance: 5 // 容忍5秒偏差 });另一个是别只依赖exp做业务时效控制。JWT的exp是"绝对时间点",如果你需要"用户30分钟无操作才过期"这种滑动过期,JWT本身做不到,得靠续签机制,下一节讲。
5. Token续签的三种方案:别让用户每15分钟登录一次
5.1 双Token方案:access + refresh
这是目前最主流的方案。核心思路是拆成两种token:
- access token:有效期短(15分钟~2小时),每次请求都带,用来访问业务接口。
- refresh token:有效期长(7天~30天),只在刷新时用,用来换新的access token。它不该在每个请求里传来传去。
流程是这样的:
- 用户登录,服务端同时下发access token和refresh token。
- 前端正常请求带access token。
- access token过期,接口返回
TOKEN_EXPIRED。 - 前端用refresh token调刷新接口,换回一对新的token。
- 刷新接口校验refresh token是否有效,无效就要求重新登录。
为什么这么设计?因为access token频繁在网络上传输,暴露面大,所以有效期要短;refresh token只在刷新时用,暴露面小,有效期可以长。两者配合,既安全又不用频繁登录。
refresh token的存储我强烈建议放HttpOnly Cookie,而不是localStorage,原因是JS读不到它,能有效缓解XSS窃取。第6节详细说。
5.2 滑动续期的并发坑:同一时刻多个请求都去刷新
双Token方案有个几乎必然会踩的坑:并发刷新。
场景是这样的:用户在页面上打开了三个标签页,或者一个页面同时发了5个请求。access token在同一时刻过期,这5个请求同时收到TOKEN_EXPIRED,于是5个刷新请求同时打向刷新接口。如果服务端对refresh token做了"一次性使用"(刷新后旧refresh token立即失效,这是安全最佳实践),那么只有第一个刷新请求成功,后面4个拿到的是"refresh token已失效",前端就会误判为登录失效,把用户踢出去。
这个坑我第一次遇到时排查了很久,因为日志看起来就是"refresh token莫名其妙失效了"。
解决方案有两个方向:
前端侧:加刷新锁。用一个Promise作为锁,第一个触发刷新的请求拿到锁去执行,后面的请求都等待这个Promise的结果,拿到新token后再重放原请求。
let refreshPromise = null; async function refreshToken() { if (refreshPromise) return refreshPromise; refreshPromise = doRefresh().finally(() => { refreshPromise = null; }); return refreshPromise; }服务端侧:宽限期。旧refresh token在刷新后不立即删,而是打上一个"已轮换"标记,保留一个短窗口(比如30秒),在这个窗口内用旧refresh token来刷新,返回同一个新token对,不报错。这样即使并发,大家拿到的结果也一致。
我倾向于两个都做:前端加锁减少无效请求,服务端加宽限期兜底。双保险。
5.3 主动踢人与黑名单:jti配Redis
JWT是无状态的,这是优点也是缺点。缺点就是——你没法主动让一个还没过期的token失效。用户改了密码、管理员封了账号、用户点了"退出登录",那个token在过期前依然能用。
解决方案是引入一个"有状态的补丁":用jti + Redis。
签发时给每个token一个唯一jti,同时往Redis写一条记录,key是jti,value可以是空或者一些元信息,过期时间设成和token的exp一致。校验时除了验签名,还要查Redis里jti是否存在。要踢人,就把对应jti从Redis删掉。
async function verifyWithBlacklist(token, secret, redis) { const decoded = jwt.verify(token, secret, { algorithms: ['HS256'] }); const exists = await redis.exists(`jwt:${decoded.jti}`); if (!exists) throw new Error('TOKEN_REVOKED'); return decoded; }这么做相当于每个请求多一次Redis查询,JWT的"无状态"优势被削弱了。所以要不要用,取决于业务:
- 对"能主动踢人"没硬要求的(比如内部工具),可以不做,靠短有效期兜底。
- 对安全敏感的(金融、后台管理),必须做。Redis的这点开销换来的是可控性,值。
还有个更细粒度的做法:不按单个token踢,而是按用户维度维护一个"token签发时间下限"。用户改密码时,记录一个passwordChangedAt时间戳,校验时如果token的iat早于它,就判定失效。这样一次操作能踢掉该用户所有旧token,不用逐个删jti。适合"改密码后全端下线"这种需求。
6. 让篡改无处可逃:签名之外的加固清单
6.1 alg=none攻击:把算法决定权夺回来
这是JWT最经典的漏洞之一,必须讲清楚。
早期不少JWT库在校验时,会读取token Header里的alg字段来决定用什么算法验签。攻击者就构造一个"alg":"none"的token,签名段留空。如果服务端的库支持none算法,并且没做白名单校验,那这个token就会被当成"合法的、没有签名的token"放行。攻击者可以随意伪造任何用户身份。
防御其实就一句话:校验时显式指定允许的算法白名单。
jwt.verify(token, secret, { algorithms: ['HS256'] });Java侧同理,jjwt较新的版本已经默认拒绝none,但老版本要注意。另外还有一类变种攻击:把RS256的token改成HS256,然后用公钥当HS256的密钥去签名。因为服务端如果用的是RS256的公钥校验,而校验逻辑又信任了token里的alg=HS256,它就会拿公钥去当HMAC密钥验证——而公钥是公开的,攻击者能拿到,于是签名就对上了。
同一个防御原则:算法白名单写死,绝不信任token自带的alg。
6.2 敏感字段二次加密:给Payload加一层保险
标准JWT的Payload能被任何人解开,这是前面反复强调的点。如果你确实需要传一些敏感标识,又不想上JWE,可以手动对敏感字段做一层对称加密再放进Payload。
const crypto = require('crypto'); function encryptField(plain, key) { const iv = crypto.randomBytes(12); const cipher = crypto.createCipheriv('aes-256-gcm', key, iv); const enc = Buffer.concat([cipher.update(plain, 'utf8'), cipher.final()]); const tag = cipher.getAuthTag(); return Buffer.concat([iv, tag, enc]).toString('base64url'); }放出的是密文,校验方用同一个密钥解密。这样即使token被截获,敏感字段也不暴露。代价是Payload变大(密文比明文长),以及多一次加解密开销。用不用,看你的敏感程度。
6.3 常见JWT漏洞与修复对照表
把常见的坑整理成表,方便你对照自查:
| 漏洞 | 成因 | 危害 | 修复 |
|---|---|---|---|
| alg=none | 信任token自带alg,未做白名单 | 伪造任意身份 | 校验时指定算法白名单 |
| 算法混淆(RS256转HS256) | 用公钥当HMAC密钥验签 | 伪造token | 白名单+按kid取正确密钥类型 |
| 弱密钥 | 密钥太短或可猜测 | 暴力破解后伪造 | 密钥熵足够,HS256≥32字节 |
| 密钥硬编码 | secret写死在代码/仓库 | 泄露即沦陷 | 环境变量/配置中心+轮换 |
| 无过期时间 | 未设exp | token永久有效 | 必设exp,access短有效期 |
| Payload放敏感数据 | 误解为加密 | 信息泄露 | 敏感字段加密或放服务端 |
| 无Token撤销机制 | 纯无状态 | 无法踢人 | jti+Redis,或iat下限 |
7. SPA里落地JWT:存储位置的抉择与验证码配合
7.1 localStorage、sessionStorage还是HttpOnly Cookie
前端分离项目最纠结的就是token存哪。三个选项各有优劣:
| 存储位置 | XSS风险 | CSRF风险 | 跨标签页共享 | 备注 |
|---|---|---|---|---|
| localStorage | 高,JS能读 | 低 | 能 | 最方便但最危险 |
| sessionStorage | 高,JS能读 | 低 | 不能 | 关闭标签页即清 |
| HttpOnly Cookie | 低,JS读不到 | 需防CSRF | 能 | 需配SameSite |
很多人图省事把access token和refresh token都塞localStorage,这其实是把安全押在"我的代码没有XSS漏洞"上——而现实中很难保证。
我的实践方案是分离存储:
- access token放内存变量(比如Vuex/Pinia的state里),页面刷新后丢失,靠refresh token重新换取。JS能读到,但它有效期短,即使被XSS偷走危害有限。
- refresh token放HttpOnly Cookie,JS读不到,专门用来刷新,并且配
SameSite=Lax或Strict防CSRF。
这样即使XSS发生,攻击者能偷到access token(15分钟就过期),但偷不到refresh token,没法长期维持访问。
注意:refresh token用Cookie,一定要配
Secure(仅HTTPS)+HttpOnly+SameSite。这三个属性一个都不能少,缺一样防护就打折。
7.2 图形验证码和JWT怎么配合
登录接口是最容易被撞库和暴力破解的入口,加图形验证码是标配。它和JWT的配合流程要理清楚:
- 前端请求验证码接口,服务端生成一个图形验证码,返回一张图片(或base64)和一个
captchaId。 - 服务端把
captchaId -> 验证码答案存进Redis,设一个短过期(比如2分钟)。 - 用户在登录页输入账号、密码、验证码,一起提交,带上
captchaId。 - 服务端先校验验证码:用
captchaId从Redis取出答案,比对。无论对错,立刻删除这条记录,防止一个验证码被重复利用。 - 验证码通过后,再校验账号密码。都通过,才签发JWT。
这里的关键点是验证码必须先于密码校验,且一次性使用。如果先校验密码,攻击者能通过响应时间或错误信息判断密码对不对,验证码就形同虚设了。另外,登录失败次数要有计数,超限就锁定该账号或该IP一段时间。
// 伪代码示意 String cached = redis.get("captcha:" + captchaId); redis.del("captcha:" + captchaId); // 立即删除,一次性 if (cached == null || !cached.equalsIgnoreCase(inputCaptcha)) { throw new BizException("验证码错误或已过期"); } // 验证码通过,再走密码校验和签发逻辑7.3 前端拦截器与401处理
前端要统一处理token过期,一般用axios拦截器:
axios.interceptors.response.use( res => res, async err => { const { response, config } = err; if (response && response.status === 401) { const code = response.data && response.data.code; if (code === 'TOKEN_EXPIRED' && !config._retry) { config._retry = true; const newToken = await refreshToken(); // 内部带并发锁 config.headers.Authorization = `Bearer ${newToken}`; return axios(config); // 重放原请求 } if (code === 'TOKEN_INVALID' || code === 'TOKEN_REVOKED') { redirectToLogin(); } } return Promise.reject(err); } );两个细节:_retry标记防止无限重试;重放请求时要把原始config带上,否则请求体和参数会丢。我见过重放后变成GET请求的bug,就是没带config导致的。
8. 线上排查记录:签名错误与过期错误怎么区分
8.1 一条排查思路:先看时间,再看签名,最后看密钥
线上遇到JWT报错,很多人第一反应是"密钥配错了吧",其实顺序反了。我总结的排查顺序是:
第一步,看错误类型。库一般会区分"签名错误(SignatureException / JsonWebTokenError)"和"过期错误(ExpiredJwtException / TokenExpiredError)"。类型不同,排查方向完全不同。
第二步,确认时间。过期错误先对时间——服务器时间准不准?签发方和校验方时间差多少?exp取值是不是太短?很多"莫名过期"其实是服务器时钟漂移。
第三步,检查签名输入。签名错误的高频原因是对token做了二次处理。比如网关在转发前把token重新拆解又拼回去、或者URL解码时把Base64URL里的-_改了、或者把token存到某个地方时截断了。校验必须用客户端传来的原始字符串,中途任何一次解析再序列化都可能破坏签名。
第四步,才是查密钥。确认验签用的密钥和签发时是不是同一个。特别是配置了kid的场景,要确认取到的是对应kid的密钥,而不是拿错了。
8.2 常见报错对照表
| 报错信息 | 大概率原因 | 处理方向 |
|---|---|---|
| SignatureException | 密钥不一致/中途改过token | 核对密钥与原始串 |
| ExpiredJwtException | 已过期/时钟偏差 | 检查exp与服务器时间 |
| MalformedJwtException | 格式错误/被截断 | 检查三段是否完整 |
| UnsupportedJwtException | 算法不支持/alg=none | 配置算法白名单 |
| InvalidClaimException | iss/aud校验失败 | 核对签发者与受众配置 |
| 签名校验偶发失败 | 多实例密钥不一致 | 统一密钥源/配置中心 |
最后这张表里,最后一行我单独提一下。多实例部署时如果每个实例加载的密钥来源不一致(比如有的读环境变量,有的读本地文件),就会出现"在A实例校验通过、在B实例失败"的诡异现象,看起来像随机失败。统一密钥源,是分布式场景下JWT最基础也最容易被忽略的一条纪律。
密钥轮换期间,如果你用了kid,还要确保所有实例的密钥库都同步到位了再切流量,否则正在轮换的那一刻会出现一批验签失败。这个经验是我在一次灰度发布时用真实的用户投诉换来的,后来我们把密钥同步做成了配置中心推送,切流量前先确认所有实例拉取成功。