我在很多项目里见过 JWT 的身影,也见过不少人对它的态度在两个极端之间摇摆:一边觉得它不过是一串以点分隔的字符串,随便拼一拼就能用;另一边又觉得它太复杂,签名、载荷、刷新令牌这些概念绕得人头疼。这篇内容不打算站在高处喊概念,而是把 JWT 当作一具需要解剖的样本,从它的外皮一直拆到骨髓,结合我实际踩过的坑、压过的测、修过的线上问题,聊清楚每个结构为什么存在,每个字段应该怎么放,每种选型背后的代价是什么。不管你是在写用户登录、开放接口,还是内部微服务之间的身份传递,这篇文章应该都能给你一些真正能落地的参考。
1. 从 Session 到 JWT:为什么我们最终选择了无状态令牌
1.1 Session-Cookie 模式的天花板
传统的 Session-Cookie 模型是这样的:用户登录成功后,服务端在内存或 Redis 里生成一个 sessionId,并把 sessionId 写进用户的 Cookie。之后每次请求,浏览器带上 Cookie,服务端拿着 sessionId 去查对应的会话数据,确认用户是谁,再放行。
这种方式很直观,但它有一个隐含的代价——会话状态必须保存在服务端。一旦服务实例从一台变成几十台,问题就来了:用户这一次请求落在 A 机器上,下一次请求被负载均衡转发到 B 机器,B 机器上没有这个用户的 session,就只能重新登录。为了解决这个问题,常见做法是做 Session 粘滞(sticky session),或者搭一个集中式 Redis 会话中心。前者把负载均衡的灵活性牺牲掉了,后者引入了独立的会话存储节点,需要应对缓存宕机、序列化策略、扩容迁移等一系列问题。
另外一个容易被忽视的问题就是 API 化趋势。现在不止是浏览器在访问我们的后端,小程序、App、第三方开放平台都可能要调用接口。移动端原生应用不一定好操作 Cookie,跨域请求面对 CORS 限制时,Cookie 也显得笨重。这个时候,服务端其实更希望的是:你每次请求都给我一个明确的令牌(Token),我只要验明令牌自身的合法性就知道你是谁,不需要记住你。JWT 恰好就是这种模式下最常用的令牌载体。
1.2 JWT 的适用场景与不适合场景
JWT 的核心卖点是"无状态":服务端不保存用户的登录记录,用户是谁,能干什么,都体现在令牌本身。这让服务端可以轻松水平扩展——任何一台机器只要持有正确的密钥,就能验证令牌、解析载荷,不需要访问共享存储。这一点在微服务架构里尤其诱人:一个网关校验完 JWT,把它透传给下游服务,下游服务再验一次签名,整个链路不需要反复查询用户中心。
但 JWT 不是万能的。它天生就有几个局限:第一,令牌一旦签发,在有效期内无法从服务端直接撤销,除非你引入黑名单机制,而黑名单的出现又会削弱"无状态"这个优势;第二,令牌内容越长,每个请求携带的开销越大;第三,如果把敏感信息写进载荷,令牌一旦泄露,加密签名只能保证数据不被篡改,不能保证数据不被读取。所以,JWT 适合做认证和轻量级授权,不适合在令牌里塞大量隐私数据,也不适合对即时注销有强要求的场景。
我见过一个危险的做法:为了让前端能实时显示用户角色变更,有人把用户的所有权限点全都塞进 JWT,然后每隔几分钟重新签发一次。这既让令牌膨胀到好几 KB,又把权限变更的实时性完全寄托在重签频率上。用 JWT 做权限控制,一般只适合"角色相对稳定"的业务;如果权限变化很频繁,更合理的做法是 JWT 里只放一个用户标识,具体权限仍然查库或者走缓存,每次请求再动态拉取。
2. JWT 三段式解剖:Header、Payload、Signature 到底藏了什么
2.1 Header:算法声明与类型声明
一个标准的 JWT 字符串长得像这样:eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiaWF0IjoxNTE2MjM5MDIyfQ.SflKxwRJSMeKKF2QT4fwpMeJf36POk6yJV_adQssw5c
拿到这段字符串,先用眼睛看:它由两个点分隔成三个部分,分别对应 Header、Payload、Signature。第一眼看上去像乱码,其实是 Base64Url 编码后的 JSON。
把第一段 Base64Url 解码出来,通常是这样的:
{ "alg": "HS256", "typ": "JWT" }alg声明了签名算法,typ声明了令牌类型。有些实现还会加cty(Content Type),不过绝大多数场景用不到。很多人以为 Header 只是一个无关紧要的声明,但实际上alg是安全的关键。历史上有过著名的签名算法混淆攻击:服务端验证时如果没有强制指定允许的算法,而是完全信任 Header 里的alg字段,攻击者可以把RS256改成HS256,诱骗服务端用公钥作为 HMAC 的密钥来验签。因为公钥通常是公开的,攻击者等于拿到了签名密钥,直接伪造任意 Token。所以,任何 JWT 库的服务端配置里,都要显式声明允许的签名算法集合,而不是全盘接受令牌头里的声明。
2.2 Payload:claim 的规矩与自定义字段
中间那段是 Payload,解码后也是一段 JSON。官方规定了三类 claim:
- Registered claims:预定义的一些键,代表基础语义。比如
iss(签发者)、sub(面向的用户)、aud(接收方)、exp(过期时间)、nbf(生效时间)、iat(签发时间)、jti(唯一 ID)。 - Public claims:可以自定义,但为了避免冲突,建议在 IANA JSON Web Token Claims 注册表里登记,或者在命名空间内使用。
- Private claims:服务端和客户端私自定义的字段,比如
role、tenantId这类业务字段。
实操中,exp是几乎必然会用到的。它必须是 NumericDate 格式,即自 1970-01-01T00:00:00Z 起的秒数。很多人会在这里踩坑:如果不小心把毫秒时间戳当秒数填进去,Token 的有效期就会变成理论上的几十万年后,等于永久有效;反过来把秒数当毫秒,又会导致 Token 一签发就过期。类似这样的单位混淆问题,在联调阶段几乎每个团队都会遇到,排查方式也很简单——把 JWT 解出来看一眼exp,做个换算。
还有一个值得注意的字段是iat(签发时间)。虽然 JWT 库不会因为你填了iat就自动拒绝"过期"令牌,但它对审计和后续的 Token 版本控制很有用。比如你想强制用户改密码后所有旧 Token 失效,就可以在库里记录密码最后修改时间和iat对比。
自定义 claim 也不是随便乱放。我的习惯是:只放稳定、非敏感、能被快速读取的标识信息,例如用户 ID、租户 ID、角色(如果角色变更不频繁)。像手机号、邮箱这类字段,除非有强需求且接受泄露风险,否则最好不放。真要放了,一定要明白 JWT 的签名只是防篡改,并不加密,任何人都能 Base64 解码看到明文。
2.3 Signature:防篡改的基石
Signature 是 JWT 的第三段,也是整个令牌合法性的根本保证。它的算法很简单,把编码后的 Header、Payload 以及一个密钥作为输入,然后跑一次签名算法。例如 HMAC SHA-256 的签名过程,本质就是:
- 用密钥,并结合 SHA-256 哈希函数对
base64Url(Header) + "." + base64Url(Payload)做两次 HMAC 运算。 - 输出的二进制结果再做一次 Base64Url 编码,拼在头部和载荷之后。
验证方要做的事情是:把收到的 Header 和 Payload 重新组合,然后用自己持有的密钥和相同的算法再算一遍签名,和 Token 自带的签名比对。如果一致,说明 Header 和 Payload 在传输过程中没有被改动过;如果不一致,立刻拒绝这个 Token。
这里要强调一个关键认知:**签名只能保证"数据没有被篡改",不能保证"数据不被人看见"。**很多业务方把 JWT 当成加密令牌来用,觉得只要签了名就安全了,这是误解。Base64Url 不是加密,充其量是编码,解码不需要任何密钥。真正要加密内容,需要引入 JWE(JSON Web Encryption)规范,或者干脆不要在 JWT 里放敏感明文。
2.4 Base64Url 与普通 Base64 的区别
JWT 使用 Base64Url 编码,不是普通 Base64,这一处小差异经常坑到手动处理 JWT 的人。
普通 Base64 编码的字符集中包含+、/和=。这三个符号在 URL 中有特殊含义:+会被浏览器解析为空格,/会干扰 URL 路径分隔,=可能和查询参数的键值对格式冲突。Base64Url 的做法是把+换成-,把/换成_,同时去掉尾部填充的=。这样可以让 JWT 安全地出现在 URL 的 query 参数、Header 的 Authorization 字段等各种位置。
如果你在代码里手写了 Base64 编码去生成 JWT,十有八九会因为符号问题踩坑。标准库通常都提供了Base64URLEncoder,用的时候也需要注意 padding。JWT 的实现中,三部分都不保留 padding 的=,解码时再按需要补回去。还好绝大部分成熟的 JWT 库都替你处理好了这些细节,日常开发没有必要自己造轮子。
3. 签名算法与密钥策略:HS256、RS256 怎么选,密钥如何管
3.1 对称签名与非对称签名的本质差异
JWT 最常见的签名算法有 HS256、HS384、HS512 以及 RS256、RS384、RS512,还有 ES256 等基于椭圆曲线的算法。它们本质上分成两大类:对称签名算法(HMAC)和非对称签名算法(RSA/ECDSA)。
HS256 属于 HMAC 家族。签名和验签使用同一个密钥,所以叫对称。这意味着你的服务端拿到了这个密钥,就能一边签发 Token,一边验证 Token,逻辑很简单。但代价是:持有密钥的每一方都能签发 JWT。如果整个系统里有多个服务都持有同一把密钥,那么任何一个服务被攻破,攻击者都能合法签发任意身份的 Token。
RS256 属于 RSA 家族。它使用一对公私钥:私钥用来签发 Token,公钥用来验证签名。签名方和验证方可以分离。如果架构是网关签发、下游服务验签,那么只需要把公钥发放给各个验签服务,私钥只保存在签发中心。即使某个验签服务被攻破,攻击者也只能验证 Token,无法伪造新的合法 Token,这大大缩小了爆炸半径。
3.2 选型建议与常见误解
很多团队选型时习惯性用 RS256,理由是"非对称肯定更安全"。但实际上,安全和复杂度是同时上升的。如果只有单一服务,客户端完全没有必要区分签名角色,HS256 结合一个高强度的随机密钥就足够;相反,如果你的架构里有独立的认证中心,有十几个微服务需要验签,而且不方便每个服务都去访问一个统一密钥,那 RS256 更合适,统一下发公钥即可。
还有一个容易忽略的点:HS256 的密钥强度必须足够高,至少 256 位,也就是 32 字节以上。我见过有项目简单用一个字符串"jwt-secret"当作密钥,攻击者用字典表或者暴力枚举很快就能爆破出来。密钥要使用高熵随机数生成,比如crypto/rand或环境变量注入,并定期轮换。
ES256(ECDSA 结合 SHA-256)这几年也很流行,因为它密钥短,性能好。但 ECDSA 对随机数质量极其敏感,如果随机数生成器存在缺陷,是可能通过签名反推出私钥的。选 ES256 不是不行,但要么用成熟的云环境 KMS,要么确保随机源可靠;否则对大多数人来说,RS256 更稳妥。
3.3 密钥轮换与泄露应急
论密钥管理,绕不开轮换这个话题。即便密钥没有泄露,定期轮换也能降低长期使用同一密钥带来的风险。但 JWT 是分布式系统里的东西,如果密钥轮换了,旧的 Token 在有效期内是不是全部失效?这里有几个常见的做法。
最简单的做法:把密钥带上版本号。比如kid(Key ID)这个 Header 字段表示使用哪个密钥。验签服务手里可以同时持有多个版本的密钥,当前版本用于签发,旧版本只用于验证直到过期。签发时带上新的kid,验签时根据kid找对应的密钥验签。这样新旧 Token 可以共存,平滑过渡。
另一个做法是设置密钥的轮换周期,比如 30 天,同时在库里保留上一周期的密钥。我曾经在某个内部系统里就是这样做的:认证服务持有两个密钥对象,一个是 active,一个是 standby,每 30 天把 standby 升级为 active,再生成一个新的 standby。验签方用kid索引密钥,验证逻辑完全不受影响。
如果确认密钥泄露,要做的事就多了:立即撤销该密钥对应的所有刷新令牌,把泄露事件相关的iat记录列入黑名单,强制要求所有已登录客户端重新走一次登录接口来获取新 Token。因为攻击者拿到密钥后可能已经伪造了一批合法 Token,黑名单的范围要覆盖到泄露发生之后的一段时间,而不是只处理当下一批。
4. Access Token 与 Refresh Token:无状态下的会话续期设计
4.1 为什么 Access Token 不宜设置过长有效期
JWT 的无状态特性带来了一个问题:服务端无法主动让 Token 失效。如果 Access Token 有效期设成 7 天,那么用户在这 7 天内如果被拉黑、封禁、修改密码,服务端依然会认这个 Token,直到它自然过期。这会引发安全风险。
因此,Access Token 的有效期应该设计得短一些。常见的选择是 15 分钟到 2 小时,视业务风险来定。有效期越短,泄露后的攻击窗口越小,对权限变更的感知滞后也越短;但太短会让用户体验变差——用户刚开着 App 切去后台,回来 Access Token 就过期了,还需要静默刷新一次。所以这里需要在安全和体验之间找平衡,而不是死记一个数字。
Access Token 短还有一个好处:方便配合权限缓存。如果内部的权限数据有更新,最多只会在旧 Token 过期后的几十分钟内生效,而不是像一整天那么长。对于中后台管理系统,我通常会设成 1 小时;对于支付类、资金类操作,很多团队会压到 5 分钟,再配合额外的风控校验。
4.2 Refresh Token 的存储与轮换
Access Token 短了,续期怎么办?答案是 Refresh Token。Refresh Token 的有效期可以设得长一些,比如 7 天或 30 天,它只服务于一个动作——去换新的 Access Token。但这个 Token 的存储和轮换有很多讲究。
Refresh Token 一般不建议放在纯前端内存里,更不建议写进 localStorage。XSS 攻击一旦得手,localStorage 里的所有东西都会被抄走。常见的做法是放进 HttpOnly + Secure 的 Cookie 里,让 JavaScript 永远读不到,从根上拦截 XSS 偷取。如果客户端是原生 App,则应该存在系统安全存储区(如 Keychain / Keystore)里。
再说轮换。每当你用一个 Refresh Token 换了一次新的 Access Token,都应该把这个 Refresh Token 一并作废,同时签发一个全新的 Refresh Token。这样设计的好处是,Refresh Token 泄露后,如果攻击者与正常用户几乎同时使用它,系统可以发现异常:同一个 Refresh Token 被用了两次,第二次触发风控,认为要么是正常用户重新刷新了,要么是攻击者重放了,此时把该会话下所有令牌全部吊销。这就是 Refresh Token 轮换时的重放检测思路。
4.3 双 Token 联动流程详解
一次完整的双 Token 认证流程大致是这样:
- 用户发送用户名密码到认证服务,认证服务校验通过后,签发一个短有效期的 Access Token 和一个长有效期的 Refresh Token。
- 客户端拿到 Access Token 后,每次请求把它放进 Authorization 头,格式是
Bearer <token>。 - Access Token 过期后,客户端请求刷新接口,提交 Refresh Token。
- 认证服务校验 Refresh Token 是否有效、是否在黑名单里,校验通过后签发新的 Access Token(必要时同时签发新的 Refresh Token),并把旧的 Refresh Token 作废。
- 客户端更新令牌,继续正常请求。
这里有个细节:刷新接口本身也要防重放。如果攻击者盗走了 Refresh Token,在第一次刷新后,旧 Refresh Token 已经不能用了。但攻击者可能在旧 Refresh Token 还没被使用前先行刷新,正常用户后来刷新时就会发现自己拿不到新 Token。此时应该触发告警,把所有相关令牌都撤销,并提示用户重新登录。网上很多教程只讲"刷新",不讲"重放检测",但后者反而是保障 Refresh Token 安全的精髓。
5. 线上 JWT 实战排坑:从时间误差到注销失效的全面复盘
5.1 时间戳偏移引发的随机 401
JWT 的exp和iat是绝对时间戳,依赖客户端机器和服务端机器之间的时钟同步。我曾经遇到一个诡异的现象:用户明明刚登录,过几分钟就收到 401,而且不是所有人,只是个别用户。排查到最后,发现那个用户手机的本地时钟比真实时间快了十来分钟。由于当时某跨平台系统把 JWT 的nbf(生效时间)设成了iat - 5分钟,本地时钟快了几分钟,签发时nbf会变成未来时间,客户端立刻拿 Token 去请求服务端,服务端检查nbf时发现当前时间还早于生效时间,就拒绝了。
这个坑的教训是两点。其一,客户端签发 Token 前,要尽量从服务端时间校准,或者别过于依赖nbf的容错;其二,服务端验签的时候,clockTolerance(时钟容差)要设成一个合理值,比如 30 秒到 5 分钟,避免因为几十秒的时钟偏移导致随机失败。很多 JWT 库默认 0 容差,那种情况下任何时钟偏移都可能踩雷。
5.2 Token 注销失效的三种补救方案
JWT 无法真正主动失效这一点,让业务方非常头疼。这里给出三种常用补救方案,按实现成本从低到高排序。
方案一是设置一个jti(JWT ID)字段。签发 Token 时生成一个随机唯一 ID,服务端在 Redis 里维护一个jti黑名单,注销时把对应的jti加进去,过期时间设为该 Token 的剩余有效时长,这样可以有效让旧 Token 失效。缺点是每次请求都要查一下 Redis,多一次网络交互,和"纯无状态"的目标有点背离。
方案二是在数据库里记录用户密码的修改时间。验签时读取该用户最近一次密码变更时间,和 JWT 中iat对比,如果iat早于密码变更时间,就认为过期。这种方式不需要维护黑名单,但多了一次数据库或缓存查询。
方案三是根本不依赖服务端主动撤,而是让业务接口在鉴权通过后,再拿 JWT 里的uid去查权限缓存,权限缓存按用户维度维护了一个令牌版本号。只要版本号不匹配,就拒绝。这其实跳出了 JWT 本身的授权逻辑,把 JWT 只当成身份凭证,权限实时性完全由服务端掌握。我认为在权限敏感系统中,方案三才是最稳的。
5.3 JWT 膨胀与请求体积控制
JWT 的 Payload 是按字面量一字不落地放进请求头的。Token 每多 1 字节,每次 HTTP 请求就要多带 1 字节,如果有 1000 TPS,一天积累起来的额外带宽是不小的数字。更重要的是,加长的 Header 可能会让某些网络基础设施(比如负载均衡的 Header 限制)直接报错。
我曾经接手过一个项目,开发同学把用户完整的菜单树、权限码列表、昵称、头像 URL 全塞进了 JWT,结果 Token 超过 8 KB。那段时间经常出现偶尔 400 或者 431 错误,排查到最后才知道是网关对超长 Header 有限制。后来我们把 Payload 精简成:sub(用户 ID)、tid(租户 ID)、role(角色 ID)、exp、iat、jti,权限信息全部改为请求时动态查。Token 长度瞬间降到了不到 500 字节,请求处理速度提上来了,也解决了网关 Header 上限的问题。
这里给一个明确的建议:JWT 中的sub不要塞业务主键以外的复合信息;凡是超过 2 KB 的 Token,都应该审查一下是否把不该带的东西带进去了。权限字段过多时可以只放一个不变的内存标记,把真正动态的权限点都放到请求链路里向权限服务请求。
5.4 同源策略下 CSRF 与 XSS 博弈
前端拿到 JWT 后,如果存在 localStorage 里,最大的敌人是 XSS。攻击者注入一段脚本,直接localStorage.getItem('token')就能偷走 Token,这种攻击太容易了。而如果把 token 放在 Cookie 里,则需要组织 CSRF——攻击者完全可以写一个自动提交的表单,利用同源策略的漏洞,带着 Cookie 发起跨站请求,虽然攻击者读不到响应,但如果接口副作用足够大,一样可能造成破坏。
所以 JWT 存放位置有一条经验法则:如果不用 Cookie 而是用 Authorization Header 携带 Token,那么至少不会被 CSRF 打到;但要防 XSS,Token 不能简单放在 localStorage,可以通过内存变量加刷新机制降低 XSS 窃取风险。另一方面,如果选择 HttpOnly Cookie 存 Refresh Token,可以防 XSS 读取,但必须给每个状态变更接口加 CSRF Token 或校验Origin、Referer头。
还有个小细节:回退页面 URL 别带 Token。很多前端同学喜欢在登录成功后用?token=xxx跳转,这等于把 Token 写进了浏览器历史、站点统计脚本、referer 头里。我见过不止一次 Token 从 Referer 漏出去的案例,之后我们统一改成登录后跳到空白页,由前端内存接收令牌,不再让 Token 出现在 URL 上。
6. 性能与安全调优:把 JWT 用到游刃有余的最后一公里
6.1 解析性能优化与本地缓存
JWT 的验签本质上是一次哈希或非对称计算,开一下性能测试会发现它比查一次内存缓存慢得多。如果只是底层拒绝无效 Token,开销还能接受;但如果用户量巨大,比如网关层每秒要验 10 万个 JWT,优化空间是实打实的。
一个常见的优化思路是在网关或业务服务里加一层 Token 解析缓存。比如每个节点的本地内存中,缓存Token签名 -> 解析结果,缓存时间设为 30 秒或 1 分钟。虽然 JWT 的有效期可能是 1 小时,但缓存 1 分钟意味着同一 Token 在 1 分钟内的重复验签不再需要计算签名,最多延迟 1 分钟响应权限变化,对大多数业务无感知。这个缓存开销极低,却能把验签耗时压下去一个数量级。
另一种优化是减少 Payload 解码频率。如果你的业务只是从 JWT 里拿用户 ID,而验签已经由网关完成,你可以配置信任网关透传的X-User-Id头,不要在下游服务再完整验签。当然,这要建立在服务之间信任边界明确的基础上。
6.2 压测中发现的签名 CPU 热点
有次我对一个认证接口做压测,发现 TPS 上不去,CPU 先冲到 90%。用 profiler 一看,热点全在 RSA 验签那段。当时策略是 HS256,还没那么严重,但我们计划迁移到 RS256,结果一测试 RS256 的验签消耗比 HS256 要高一截。后来优化手段有三招:第一,升级算法相关库到新版本,换上更高效的实现;第二,把验签的结果放到 Caffeine 本地缓存;第三,把短期高频验签的接口调整为在网关统一验签并下发用户信息头,让下游业务不再各自验签。三招做完,TPS 提升了接近一倍。
如果是 ES256,性能更优,但前文说过有随机数质量风险。选型时别只看基准测试数字,还要考虑团队对底层原理的掌握程度。大多数团队踩不起 ECDSA 随机数泄露的坑,所以选 RS256 配合理缓存是性价比最高的组合。
6.3 日志脱敏与 payload 最小化
我见过一份线上日志,里面直接把 Authorization 头原样打了出来。JWT 虽然包含签名,但 Payload 是明文编码,日志一旦泄露,等于把用户身份关联信息交给日志阅读者。正确的做法是在日志中只记录jti或 Token 的哈希摘要,避免打印完整 Token。
同理,异常信息里也不要拼进完整 JWT。很多框架在验签失败时会抛出携带令牌详情的异常,如果你直接在全局异常处理中把ex.getMessage()抛给前端,实际上就是把未解码的 JWT 原文泄露给了调用方。生产环境应统一屏蔽这些细节,只看脱敏后的日志。
6.4 我的一点个人体会
回到"庖丁解牛"这个词,庖丁不是靠一把好刀才做到游刃有余,而是对牛的结构了然于胸,刀只是顺着肌理走。JWT 也是一样——看起来是一个简单的 Token,但它背后的签名算法选型、密钥管理、载荷设计、续期策略和撤销方案,每一项都是在和业务的取舍做博弈。我见过太多团队因为只用了 JWT 的皮毛,结果被一堆玄学问题折磨得焦头烂额;也见过把 JWT 当作整个权限体系核心,最后引火烧身的案例。只有把这些结构搞通、搞透,JWT 才会真正变成一把顺手的好刀,也才敢说自己是"庖丁解牛"而不是"乱砍一通"。
最后再分享一个小习惯:每个 JWT 项目的仓库里,都建议放一个"令牌体检"的脚本或页面,能一键解码、校验签名、显示剩余有效期、查看 payload 大小。每次改签发的逻辑后,都拿它过一遍,能省下后面很多联调时间。