前几天帮一个朋友排查线上问题:他们的后台服务从单体拆成了三个实例,挂上负载均衡之后,用户每隔几分钟就莫名其妙退出登录,重新登录又能用一会儿,然后再次被踢。日志里没有任何异常,接口返回全是 401。我第一反应是 JWT 校验环节出了问题,但翻了一遍代码,签名密钥一致、过期时间也正常。真正的原因藏在他们"又退回了 Session 方案"这件事上——用户态存在单机内存里,请求被轮询到另一台机器,自然找不到会话。这个场景几乎每年都会在各种团队里重演一次,而它恰好说明了 JWT 被设计出来要解决的核心问题:让服务端在不保存会话状态的前提下,仍然能确认"你是谁"。
这篇内容会把 JWT 从结构、签名原理讲到续签设计、常见漏洞,再到 SPA 项目里和图形验证码的配合落地。不管你是刚开始接触接口鉴权,还是已经在生产环境跑了两三年 JWT、想回头补齐安全细节,下面这些拆解应该都能对得上你的实际场景。我会尽量把每一步的"为什么这么做"讲清楚,而不是只甩一段能跑通的代码。
1. 从登录态互踢说起:JWT 存在的理由和它的边界
1.1 服务端保存会话的三个现实痛点
传统 Session 方案的逻辑很直白:用户登录成功后,服务端生成一个随机字符串作为 sessionId,存进内存或 Redis,再通过 Cookie 把 sessionId 交给浏览器。之后每次请求带上这个 id,服务端查一次存储,就知道是谁。这个模式用了二十多年,本身没有任何问题,问题出在部署形态变了之后。
第一是横向扩容。会话存在进程内存里,三个实例就有三份互不相通的会话表,用户请求落到哪台机器全看负载均衡的心情。要解决就得加"粘性会话",让同一个用户的请求固定打到同一台机器上。但粘性会话在实例重启、缩容时会导致一批用户集体掉线,运维体验很差。
第二是跨端适配。移动端 App、小程序、桌面客户端对 Cookie 的支持各有各的脾气,尤其是原生 App 里手动管理 Cookie 相当别扭。很多团队的做法是把 sessionId 塞进自定义请求头里传,这时候 Cookie 那套自动携带、自动过期的机制就用不上了,等于自己重新实现了一遍。
第三是集中存储的额外开销。把会话挪到 Redis 是主流解法,但每次请求都要多一次网络往返去查 Redis。单次几毫秒看起来不多,在高并发接口上会被放大成实实在在的延迟,而且 Redis 一旦抖动或者被打满,整个登录体系直接瘫痪——这是典型的把鸡蛋放在一个篮子里的架构。
JWT 的思路是换个方向:与其让服务端记住每个用户,不如让用户自己带着一份签过名的凭证。服务端只要验签通过,就能从凭证里直接读出用户身份,全程不碰任何存储。这就是"无状态"三个字最朴素的含义。
1.2 token、JWT、Session 三者到底是什么关系
这里有个特别常见的混淆:很多人把"token"和"JWT"当成同义词用,实际上它们根本不在一个层级上。我在面试里问过不少人"你们项目里用的 token 是什么格式",回答"就是 JWT 啊"的占了大多数,但再追问一句"这个字符串是哪几部分拼起来的、用什么算法签的",能说清楚的就不多了。
| 概念 | 本质 | 状态存放位置 | 典型载体 |
|---|---|---|---|
| Session | 一种服务端会话机制 | 服务端(内存/Redis/数据库) | Cookie 里的 sessionId |
| Token | 一个泛称,指代任意形式的访问凭证 | 取决于具体实现 | 请求头、URL 参数、Cookie |
| JWT | 一种具体的 token 编码与签名规范(RFC 7519) | 凭证自身携带,服务端不存 | 请求头 Authorization |
用一句话概括:JWT 是 token 的一种实现格式,但 token 不等于 JWT。你完全可以设计一个 32 位随机字符串当 token,服务端拿它去 Redis 查用户信息,这也是 token,只是它是有状态的。JWT 的特殊之处在于它把用户信息和签名一起打包进了这个字符串本身,所以服务端不需要查任何地方。
搞清这层关系之后,很多争论就自然消解了。比如"JWT 能不能被吊销"这个问题,答案取决于你愿不愿意为吊销功能重新引入服务端状态——一旦引入,你就又回到了有状态的老路上,只是把 sessionId 换成了 jti 而已。
1.3 哪些场景不适合上 JWT
JWT 不是银弹,我在实际项目里见过好几次"为了用而用"翻车的案例,这里列几个明确不建议的场景。
需要即时吊销权限的系统要慎重。比如后台管理系统,管理员点一下"踢出该用户",期望的是下一个请求立刻失效。纯无状态的 JWT 做不到这一点,因为凭证在用户手里,服务端说了不算。你只能靠短过期时间加黑名单来曲线救国,而黑名单本身就是状态存储,等于绕了一圈又回去了。
凭证体积敏感的场景也要慎重。JWT 的 payload 是 base64 编码的 JSON,塞进去的字段越多,每次请求的头部就越大。有些团队图省事,把用户的角色列表、权限树、部门信息全塞进 payload,最后单个 token 超过 4KB,每个请求都拖着几 KB 的头部在网络里跑,移动端弱网下体验非常糟糕。
还有一点必须提前说清楚:JWT 的 payload 是明文可读的。它只做了 base64url 编码,任何人拿到这个字符串都能解码出里面的内容,签名保护的是"内容没被改过",不是"内容不被看到"。所以手机号、身份证号、内部接口地址这类信息,绝对不要往 payload 里放。
2. 拆开三段结构:JWT 的组成与验签的数学基础
2.1 Header 里的 alg 与 typ 字段
一个标准的 JWT 字符串长这样,中间用两个点分成三段:
eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMDA4NiIsIm5hbWUiOiJ6aGFuZ3NhbiIsImlhdCI6MTczNTY4OTYwMH0.dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk第一段是 Header,解码出来是一个很小的 JSON:
{ "alg": "HS256", "typ": "JWT" }typ是类型声明,基本固定为 JWT,作用不大但规范要求带上。真正关键的是alg,它声明了这份凭证用了哪种签名算法。这个字段之所以重要,是因为验签方需要读它来决定用什么算法去验证,而这个"读"的动作本身就埋下了后面第 3 章要讲的那类漏洞。
常见的算法分两大派系。对称派以HS256(HMAC-SHA256)为代表,签名和验签用的是同一个密钥,部署简单,适合单体服务或者内部服务之间互相调用。非对称派以RS256(RSA-SHA256)和ES256(ECDSA-SHA256)为代表,私钥签名、公钥验签,适合签发方和验证方分离的架构,比如认证中心给一堆业务服务发凭证,业务服务只拿公钥就能验,不用担心私钥泄露。
2.2 Payload 的注册声明与自定义声明的边界
第二段是 Payload,同样是一个 JSON,字段可以分成三类。
注册声明是规范里预定义好的字段,虽然都不是强制的,但强烈建议按约定使用:iss表示签发者,sub表示主体(通常是用户 id),aud表示接收方,exp是过期时间戳,nbf是生效时间戳,iat是签发时间戳,jti是这份凭证的唯一编号。其中exp和jti的利用率最高,前者控制生命周期,后者用于防重放和做黑名单。
公共声明是可以自己定义、但建议去 IANA 注册避免冲突的字段,日常项目里基本用不到。
私有声明就是你和前后端约定好的自定义字段,比如name、role、tenantId。这里有两条经验:一是字段名尽量短,role比userRoleList省字符;二是只放做鉴权判断必需的最小信息,需要在后续业务里频繁读取、又变化不频繁的数据(比如用户名)可以放,会实时变化的数据(比如账户余额、权限明细)不要放,否则用户改了权限还得等 token 过期才生效。
2.3 Signature:防篡改是怎么做到的
第三段是签名,它的生成过程可以用三行伪代码说清:
data = base64url(header) + "." + base64url(payload) signature = HMAC-SHA256(data, secret) // 以 HS256 为例 jwt = data + "." + signature验签方拿到 JWT 之后,用同样的密钥和同样的算法,对前两段重新算一遍签名,然后跟第三段做比较。只要 payload 里有一个字符被改动,重新算出来的签名就会完全不同,比较失败,凭证作废。
这就是 JWT 防篡改的完整原理,没有什么魔法。攻击者能改内容,但他算不出新的签名,因为签名密钥只存在于服务端。有人会问:那我能不能暴力猜密钥?可以,这正是第 3 章要讲的弱密钥问题。
为了让你对结构有肌肉记忆,下面这段 Node.js 代码完全不依赖任何第三方库,手写一遍 JWT 的生成过程:
const crypto = require('crypto'); function base64url(buf) { return Buffer.from(buf) .toString('base64') .replace(/=/g, '') .replace(/\+/g, '-') .replace(/\//g, '_'); } function createJwt(payload, secret) { const header = { alg: 'HS256', typ: 'JWT' }; const h = base64url(JSON.stringify(header)); const p = base64url(JSON.stringify(payload)); const data = `${h}.${p}`; const sig = crypto.createHmac('sha256', secret) .update(data).digest('base64') .replace(/=/g, '').replace(/\+/g, '-').replace(/\//g, '_'); return `${data}.${sig}`; } // 验签时必须用时间安全的比较,避免时序攻击 function verifyJwt(token, secret) { const [h, p, sig] = token.split('.'); const expect = crypto.createHmac('sha256', secret) .update(`${h}.${p}`).digest('base64') .replace(/=/g, '').replace(/\+/g, '-').replace(/\//g, '_'); const a = Buffer.from(sig); const b = Buffer.from(expect); if (a.length !== b.length) return null; if (!crypto.timingSafeEqual(a, b)) return null; const payload = JSON.parse(Buffer.from(p, 'base64url').toString()); if (payload.exp && Date.now() / 1000 >= payload.exp) return null; return payload; }两个细节值得留意。第一,base64url 和普通 base64 的区别是把+换成-、/换成_,并且去掉结尾的=填充,这样做是为了让 JWT 能安全地放进 URL 和请求头。第二,比较签名时用的是timingSafeEqual而不是===,因为普通字符串比较会在第一个不同字符处提前返回,攻击者可以通过测量响应时间一个字节一个字节地猜出正确签名——这类攻击在局域网环境下是完全可行的。
生产环境当然不会手写这些,用现成的库(Node 的jsonwebtoken、Java 的jjwt、Python 的PyJWT)就够了。但手写一遍的价值在于,当库的行为不符合预期时,你知道该从哪里开始怀疑。我自己就遇到过库默认开启了某种算法白名单导致老凭证验不过的情况,如果不懂结构,那时候就只能干瞪眼。
3. 那些被真实利用过的 JWT 漏洞:从 alg=none 到 kid 注入
3.1 alg=none:最古老也最致命的绕过
这是 JWT 历史上最出名的一个坑,根源在于验签方信任了 Header 里的 alg 字段。
早期一些实现的逻辑是:先解码 Header,看看 alg 是什么,如果 alg 是 none,就直接跳过验签,认为这份凭证是"未签名"的合法凭证。攻击者只需要把 Header 改成{"alg":"none","typ":"JWT"},把 payload 里的sub改成管理员的 id,第三段留空或者随便填,服务端就会欣然接受。整个系统瞬间失守。
正确做法是在验签代码里硬编码允许的算法,而不是从 token 里读。用jsonwebtoken库的话是这样:
// 错误示范:算法由 token 自己决定 jwt.verify(token, secret); // 正确做法:白名单硬编码,token 里的 alg 说了不算 jwt.verify(token, secret, { algorithms: ['HS256'] });现在主流的库都已经默认拒绝none了,但不要假设你手上的老项目用的是新版库。特别是那些从旧版本升上来的服务,或者团队自己封装过的工具类,一定要翻出来看一眼有没有显式指定algorithms。
3.2 算法混淆:把公钥当成 HMAC 密钥用
这个洞比 alg=none 更隐蔽,原理是利用了对称和非对称算法在验签逻辑上的差异。
假设服务端用 RS256 签发凭证,公钥是公开的。验签代码如果比较粗糙,会读 Header 里的 alg,发现是 HS256,就用"密钥"去做 HMAC 验证。而它手里拿的"密钥"其实是那个公开的 RSA 公钥。攻击者知道公钥,于是用这个公钥作为 HMAC 密钥,自己签一份alg: HS256的凭证,服务端一验,签名对得上,直接通过。
防御手段和上面一样朴素:验签时锁定算法,声明用什么算法就只接受什么算法。如果你用非对称加密,验签逻辑里就写死algorithms: ['RS256'],HS256 的凭证直接扔掉,根本不给它进入 HMAC 分支的机会。
3.3 弱密钥与离线爆破
HS256 用的是共享密钥,这意味着任何拿到一份合法 JWT 的人都能离线爆破密钥,不需要和服务交互,不会被限流,也没有日志痕迹。攻击者本地跑 hashcat,配上常见的字典,几秒钟就能试出几百万个候选。
我见过一个真实项目,密钥是secret,另一个是companyname2023。这类密钥在字典里的排名非常靠前,属于一撞就中。合格的密钥至少要 32 字节的密码学随机数,生成方式:
# 生成一个 32 字节的随机密钥,用 base64 输出便于配置 node -e "console.log(require('crypto').randomBytes(32).toString('base64'))"顺带提一句运维侧的坑:密钥绝不能和代码一起进 Git 仓库。我看到太多项目把密钥写在application.yml然后提交,私有仓库也一样有风险——离职员工、第三方审计、CI 日志都可能让它流出去。放环境变量或者专门的密钥管理服务里,这是底线。
3.4 kid、jku、x5u 头部参数的注入
这三个头部参数的设计初衷是好的:kid用于标识用哪个密钥来验签(支持密钥轮换),jku和x5u用于指定公钥集的下载地址。但如果验签方不加校验地使用它们,就会变成攻击入口。
kid的经典利用是路径穿越。如果服务端实现成"根据 kid 去某个目录下读密钥文件",攻击者把 kid 设成../../../../dev/null,读出来的内容为空,用空密钥签名,服务端也就用空密钥验签,直接绕过。还有一种更狠的,如果 kid 被拼进 SQL 查询去数据库里查密钥,那就是一个货真价实的 SQL 注入。
jku和x5u的问题类似:如果验签方真的去请求这个 URL 拿公钥,攻击者把自己的域名填进去,服务端就会用攻击者提供的公钥去验攻击者自己签的凭证,形成一个完美的自证循环。
防御方法很直接:kid只接受预定义的白名单值,或者严格限定为 UUID 格式;jku、x5u一律禁用,公钥在配置里写死。如果你的业务真有密钥轮换需求,那就把密钥集缓存到本地,按 kid 在缓存里查,绝不发起外部请求。
3.5 过期校验、时钟偏移与重放
exp校验是最基础的一条,但确实有实现会漏掉。更少被注意到的是nbf和时钟偏移。多实例部署时,如果机器之间没有严格对时,A 机器签发的凭证在 B 机器上可能因为快了几秒而被判定为"尚未生效"。标准做法是给校验留一个容错窗口,通常 60 秒以内:
jwt.verify(token, secret, { algorithms: ['HS256'], clockTolerance: 60, // 容忍 60 秒的机器时间差异 issuer: 'your-auth-service', audience: 'your-api' });重放是另一个维度的问题。JWT 的有效期内,攻击者截获凭证后可以直接重放使用,签名完全合法,服务端分辨不出。缓解手段有两条:一是签发时带上jti,服务端对一次性敏感操作校验 jti 是否已用过;二是把过期时间压短,让重放窗口尽可能小。对普通读接口来说,权衡之下通常不做严格的重放防护,但对支付、改密这类操作,jti 加 Redis 去重是值得的成本。
3.6 一份可以直接对着代码逐条核对的验签清单
| 检查项 | 错误做法 | 正确做法 |
|---|---|---|
| 算法指定 | 从 token 的 alg 字段读取 | 代码里硬编码白名单 |
| 密钥强度 | secret、公司名、生日 | 32 字节以上密码学随机数 |
| 过期校验 | 不校验 exp 或校验漏掉 | 必校验 exp,留 60 秒容差 |
| kid 处理 | 直接拼路径或拼 SQL | 白名单映射,纯本地查表 |
| jku/x5u | 按 URL 远程拉取公钥 | 禁用,公钥配置写死 |
| 签名比较 | ===直接比字符串 | 时间安全比较 |
| 接收方校验 | 不管 aud 和 iss | 显式校验 iss 和 aud |
| 密钥存放 | 写在代码或配置文件提交 | 环境变量或密钥管理服务 |
4. Token 续签:双 Token、滑动过期与并发刷新的处理
4.1 为什么不能让 access token 长期有效
最省事的方案是签一个有效期七天甚至三十天的 JWT,用户登录一次管一个月。代价是:这份凭证一旦泄露,攻击者能用整整一个月,而且服务端完全无法提前终止。你把过期时间从 30 天压到 2 小时,泄露窗口就缩小到 2 小时,但用户体验变成了每两小时重新登录一次,没有人能接受。
矛盾就在这里,解决方案是把凭证拆成两种角色:短命的用来访问业务接口,长命的只用来换取新的短命凭证。
4.2 双 Token 机制的完整设计
具体设计是这样:登录成功后,服务端同时下发两个 JWT。
access token有效期 15 到 30 分钟,payload 里带用户 id、角色等鉴权必要信息。它被高频使用,但即便泄露,攻击窗口也很有限。
refresh token有效期 7 到 30 天,payload 里只放一个jti和用户 id,不放任何业务信息。它的唯一用途是调用刷新接口换取新的 access token。
关键在于refresh token 必须是有状态的——签发时把它的 jti 写进 Redis,设置和它同样的过期时间。刷新的时候除了验签,还要检查这个 jti 在 Redis 里是否存在。这样你就获得了一个可控的开关:用户点登出、管理员踢人、检测到异地登录,直接删掉 Redis 里对应的 jti,这个 refresh token 立刻作废。access token 虽然还活着,但最多再撑十几分钟就自然过期。
这套设计还有一个安全上的好处叫刷新令牌轮换:每次用 refresh token 换取新 access token 时,连同 refresh token 本身也一并换新,旧的立即失效。这样如果攻击者偷到了 refresh token 并使用,真正的用户下次刷新时就会发现自己的 token 失效了,从而触发告警。
4.3 并发刷新导致的请求风暴
这是我踩过的最真实的坑。SPA 页面加载时会并发发出五六个接口请求,如果此时 access token 刚好过期,这五六个请求会同时收到 401,然后同时触发刷新逻辑,向服务端发出五六个刷新请求。第一次刷新成功后 refresh token 已经轮换了,后面几个请求拿着旧的 refresh token 去刷新,全部失败,用户直接被踢下线。
解决思路分两层。服务端层面,给刷新加一个短暂的宽限期:旧 refresh token 被使用后不立即删除,而是标记为"已使用"并保留 30 秒,在这 30 秒内重复使用同一个旧 token 返回同一个新 token,超出窗口才判为无效。这样偶尔的并发不会误伤。客户端层面,维护一个刷新中的 Promise,401 拦截器发现已经在刷新了,就把后续请求挂起等待同一个 Promise 的结果,而不是各自发起刷新:
let refreshing = null; async function request(config) { const res = await fetch(config.url, { headers: { Authorization: `Bearer ${getAccessToken()}` } }); if (res.status !== 401) return res; if (!refreshing) { refreshing = doRefresh().finally(() => { refreshing = null; }); } await refreshing; // 所有并发请求共用同一次刷新 return fetch(config.url, { headers: { Authorization: `Bearer ${getAccessToken()}` } }); }4.4 滑动过期:另一种更轻的思路
如果你的系统对安全要求没那么极端,可以考虑滑动过期:每次请求进来,如果发现 access token 的剩余有效期不足一半,就在响应头里返回一个新签的 token,前端静默替换。这样只要用户在持续操作,登录态就永远不过期,停手超过设定时长才需要重新登录。
这个方案不需要 refresh token,也不需要额外的 Redis 存储,实现成本最低。代价是它无法主动吊销,而且响应头里的 token 需要前端配合处理,在部分网关或 CDN 上可能被过滤掉。我在个人项目和小型后台里用过几次,体验不错,但涉及资金或敏感数据的系统还是老老实实上双 token。
5. SPA 项目中的 JWT 与图形验证码:一次完整的登录链路
5.1 验证码服务的无状态设计
纯 JWT 架构下引入图形验证码,最容易做错的地方是把验证码也设计成无状态。有人想过把验证码答案放进 JWT 里发给前端,登录时再带回来对比——这等于把答案直接交给了攻击者,base64 解一下就看到了。
正确做法是:验证码的答案必须留在服务端。用户点击获取验证码时,服务端生成 4 位字符,同时生成一个 UUID 作为captchaId,把captchaId -> 答案写进 Redis,设置 5 分钟过期;接口只返回验证码图片和这个captchaId。用户提交登录时带上captchaId和用户输入的答案,服务端去 Redis 取出来比对。
这里有个必须做的动作:验证码一次性消费。比对完成后无论成功失败,立刻删除这个 key,防止攻击者在 5 分钟窗口里反复用同一个验证码撞密码。
// 校验验证码 async function checkCaptcha(captchaId, input) { const key = `captcha:${captchaId}`; const answer = await redis.get(key); await redis.del(key); // 无论对错都删,防止重放 if (!answer) return false; // 不存在或已过期 return answer.toLowerCase() === String(input).toLowerCase(); }5.2 完整登录链路的拆解
把整个流程串起来,一次登录会经过这么几步。
用户打开登录页,前端立刻请求/auth/captcha拿到captchaId和图片,同时把captchaId存进组件状态。用户填写账号、密码、验证码,点提交,请求体里带上这三样加captchaId。
服务端收到后,先校验验证码,这一步放在最前面,因为它是拦截暴力破解的第一道闸门,且成本最低。验证码通过后查用户表,取出密码哈希做比对,密码错误就返回统一错误提示,不要区分"用户不存在"和"密码错误",避免账号枚举。比对成功后签发 access token 和 refresh token,refresh token 的 jti 写 Redis。响应里返回两个 token 和用户的基本信息,注意不要把密码哈希之类的字段带出去。
前端拿到后,把 access token 放在内存变量里,refresh token 根据存储策略决定放哪(见下节),然后跳转主页面。此后每个业务请求在拦截器里自动带上Authorization: Bearer <accessToken>。
还有几处容易被忽略的细节。图形验证码建议配合登录失败计数:同一个账号连续失败 3 次才强制要求验证码,正常用户感知不到,攻击者却要付出识别验证码的成本。验证码接口本身要限流,按 IP 和按设备指纹双维度限制,否则攻击者可以无限刷新验证码把 Redis 打爆。
5.3 Token 存哪里:三种方案的取舍
这是 SPA 项目里争论最多的问题,我把三种主流方案的优劣摆在一起。
| 存储位置 | XSS 风险 | CSRF 风险 | 跨标签页共享 | 刷新后保留 |
|---|---|---|---|---|
| localStorage | 高,脚本可读 | 无 | 是 | 是 |
| 内存变量 | 低,刷新即丢 | 无 | 否 | 否 |
| HttpOnly Cookie | 低,脚本不可读 | 需要 SameSite 和 CSRF Token | 是 | 是 |
localStorage 的便利性最好,但一旦页面存在 XSS,攻击者一行localStorage.getItem('token')就把凭证偷走了,而且由于是长期存储,被偷的 token 会在过期前一直有效。内存变量的安全性最高,但刷新页面就丢,用户每次 F5 都要重新登录,体验上不可接受。
我目前用过最舒服的组合是:access token 放内存,refresh token 放 HttpOnly + Secure + SameSite=Lax 的 Cookie。这样即使有 XSS,脚本能读到 access token,但它的生命周期只有十几分钟,损失有限;refresh token 脚本读不到,攻击者拿不到续期的钥匙。CSRF 方面,由于刷新接口需要带 Cookie,配好 SameSite 基本能挡住主流的 CSRF 场景,敏感操作再叠加一层 CSRF Token 双保险。
5.4 登出、踢人与黑名单
登出接口要做两件事:删除 Redis 里 refresh token 对应的 jti,同时在前端清空内存里的 access token 并让服务端下发一个过期的 refresh Cookie。access token 本身不用管,它在十几分钟内自然过期。
如果需要更严格的即时失效(比如管理员踢人),可以引入 access token 黑名单:签发时给每个 access token 加jti,登出时把 jti 写进 Redis,TTL 设为该 token 的剩余有效期。鉴权中间件在校验通过后再查一次黑名单。这里的开销是不可忽略的,因为它让每个请求都多了一次 Redis 查询,等于部分放弃无状态带来的收益。我的建议是只在确实需要严格失效的场景开启,别默认全站启用。
6. 上线前最后一遍检查:几个容易被忽略的细节
6.1 base64url 和填充字符
不同语言的标准库对 base64url 的支持程度不一样。有的会自动去掉=填充,有的保留,有的把-和_又换回了+和/。当服务端用 Java 签发、网关用 Nginx 的 Lua 脚本验签、前端用 JavaScript 解析时,这三方的实现差异会导致"签名明明没错却验不过"的诡异问题。
排查方法很土但有效:把服务端签好的 token 三段分别解码,再手动重新编码一遍,看结果和原文是否一致。只要有一端不一致,问题就在那一端。判断 token 是否被截断或改写,也可以用这个办法——JWT 的三段长度和点号的位置是有明确规律的,任何中间环节的换行、空格注入都会让签名校验失败。
6.2 时钟偏移的排查
多实例部署时nbf和iat引发的"token 无效"往往非常偶发,因为只有落到时钟快的那台机器上才会出现。我的做法是在鉴权中间件里把当前机器时间、token 的 iat 和 exp 一起打进日志:
logger.debug('jwt-time-check', { now: Math.floor(Date.now() / 1000), iat: payload.iat, exp: payload.exp, host: process.env.HOSTNAME });出现问题时,对比不同机器的now值就能立刻定位。生产环境务必开启 NTP 对时,并且把clockTolerance配置成可调参数,方便应急时临时放宽而不用重新发版。
6.3 payload 里到底该放什么
我把这条放在最后,是因为它看着简单,实际最容易失控。项目初期 payload 只有sub和exp,半年后就变成了十几个字段,因为每个新功能都觉得"顺手放进去省一次查询"。
判断标准可以简化成一句话:这个字段是否会被用来做鉴权判断,且在整个 token 生命周期内不会变化。用户 id、角色、租户 id 符合,可以放;用户名、头像、余额、权限菜单不符合,不要放。角色虽然是鉴权必需的,但如果系统支持实时改角色并期望立即生效,那也得改成每次查缓存。
一个可以随时跑的体检手段是统计线上 token 的平均长度,超过 800 字节就该拉个清单看看哪些字段是多余的。
# 采集一段时间内 Authorization 头的长度分布 grep -o 'Bearer [A-Za-z0-9_.-]*' access.log | awk '{print length($2)}' | sort -n | tail -206.4 密钥轮换怎么做到无缝
密钥一旦泄露需要更换,但直接换掉会让所有在线用户的 token 立即失效。稳妥做法是准备两个密钥:新密钥用于签发,验证时先试新密钥,失败再试旧密钥。等一个完整的 access token 生命周期过去,旧密钥自然淘汰,可以移除。
用jsonwebtoken的secretOrKeyProvider或者自己包一层验证逻辑都能实现。关键是把这个机制提前做好,别等到出事那天才想起来改,那时候用户被批量踢下线,压力会非常大。
我在实际使用中最深的一个体会是:JWT 的复杂度不在签发,而在验证。签发就是调用一个函数,几行代码搞定;但验证环节要考虑的事非常多——算法白名单、密钥强度、时间校验、头部参数处理、并发刷新、吊销策略,每一项漏掉都可能成为缺口。所以如果你的项目正在用 JWT,我建议花半小时把第 3 章那份检查清单对着代码逐条过一遍,大概率能发现至少一处需要修补的地方。