
JWT 这东西我第一次接触是在一个前后端分离项目的登录接口上后端把一长串xxx.yyy.zzz丢进响应体前端拿到往本地存储里一放之后每次请求在请求头里带回去服务端解一下就知道你是谁。当时觉得优雅极了直到线上出现一条反馈用户明明点了退出登录拿着旧的 JWT 还能继续调接口。那一刻我才明白这东西看着简单真正的坑全在“签名怎么验、过期怎么算、失效怎么办”这几件事上。这篇就把 JWT 的结构、签名原理、签发校验、token 续签、验证码闭环、常见漏洞与排查手法按我在实际项目里踩坑的顺序完整讲一遍。刚入门的朋友可以跟着代码走一遍全流程已经用了两三年的同学可以重点看签名防篡改、双 Token 续签和漏洞加固那几节那些地方最容易出事故。1. JWT 的三段式结构把那串乱码掰开看JWT 的全称是 JSON Web Token本质上就是一段用点号分隔的字符串形如header.payload.signature三段。很多人第一次看到它觉得这是某种加密结果其实不是——前两段只是 Base64Url 编码谁都能解开看内容。真正起保护作用的只有第三段签名。把这个认知先摆正后面很多设计决策才有依据。1.1 Header 与 Payload 里到底装了什么Header 是一个 JSON通常只有两个字段alg表示签名算法typ表示类型。Payload 也是一段 JSON官方叫 Claims声明分三类注册声明iss、sub、aud、exp、nbf、iat、jti、公共声明和私有声明。注册声明是 RFC 里规定好语义的字段比如exp是过期时间秒级 Unix 时间戳、nbf是生效时间、iat是签发时间、jti是这条 token 的唯一 ID。我见过不少项目 Payload 里塞得满满当当用户昵称、头像地址、部门、角色、权限列表甚至整个用户对象。单条 token 体积直接飙到 3KB 以上请求头跟着膨胀网关一个不留意就给截断了。合理的做法是只放“服务端验签后必须立刻用到、且不涉及敏感”的最小信息通常就是用户 ID、角色标识和 jti。{ sub: 10086, role: admin, jti: 8f14e45f-ceea-467f-a1c2-3b7f9d0e1a55, iss: api.example.com, iat: 1718000000, exp: 1718001800 }1.2 解码一下你就明白为什么不能放敏感信息想验证“前两段只是编码”这件事随便拿一条 token 试就行。把中间那段拿出来补上 Base64 的填充字符直接解# 取出 payload 段第二段补 padding 后解码 echo eyJzdWIiOiIxMDA4NiIsInJvbGUiOiJhZG1pbiJ9 | base64 -d # 输出{sub:10086,role:admin}注意 Base64Url 和标准 Base64 的区别它把换成-/换成_并且去掉了末尾的填充。有些语言的标准库解码时对-_和缺省 padding 不容忍会报“非法字符”或“长度不合法”排查这类报错时先看这一点。明白了这一点结论就很直接手机号、身份证、邮箱、内部接口地址这类信息一个都不要写进 Payload。它不是保险箱只是一个方便传输的明文信封。1.3 三种会话方案怎么选后台管理系统、App 接口、开放平台各自适配的方案并不一样。我把实际用过的三种形态整理成一张表方便对照对比维度JWT自包含服务端 Session不透明 Token白名单状态存放客户端持有服务端不存服务端存服务端存单次校验开销一次签名验签纯 CPU一次存储查询一次存储查询即时踢下线难需要黑名单兜底容易容易水平扩展天然无状态需共享存储需共享存储信息携带量可携带少量声明通常只存 ID通常只存 ID适合场景短时效接口鉴权、跨服务传递身份传统单体后台对撤销要求极高的系统我的选型习惯是接口鉴权用 JWT踢人下线需求强的场景加 Redis 黑名单跨服务调用用 JWT 传递身份。别硬拿 JWT 去当 Session 用那等于把两边的缺点凑一块儿了。2. 签名与防篡改jwt 如何防止数据被串改标题里这个问题是问得最多的Payload 明明能解开那攻击者改一下role改成 admin 不就行了答案是改不了因为签名会跟着失效。这一节把签名的完整链路拆开讲清楚。2.1 签名生成的完整计算过程签名的输入不是整个 token而是“编码后的 header 点号 编码后的 payload”这个字符串业内叫 signing input。以 HS256 为例计算过程是这样把 Header 序列化成 JSON做 Base64Url 编码得到 A把 Payload 序列化成 JSON做 Base64Url 编码得到 B拼成A . B作为待签名数据用密钥对这段数据做 HMAC-SHA256得到二进制摘要对摘要再做一次 Base64Url 编码得到 C最终 token 就是A . B . C。服务端验签时做的是同样的计算把收到的前两段拼起来用本地密钥算一次摘要然后和第三段比对。改 Payload 里的任何一个字符第一步算出来的摘要就完全不同而攻击者没有密钥算不出匹配的签名。这就是防篡改的全部原理没有魔法。有两个实现细节容易翻车一是比较签名必须用常量时间比较如MessageDigest.isEqual、crypto.timingSafeEqual否则会泄露时序信息二是 JSON 序列化时字段顺序和空格不能变某些语言里 Map 的序列化顺序不固定会出现“本地验签通过、跨服务验签失败”的诡异现象最好统一用固定结构的序列化方式。2.2 HS256 还是 RS256选型看谁验签HS256 是对称算法签发和验签用同一把密钥RS256 是非对称算法私钥签发、公钥验签。选择标准只有一条验签方和签发方是不是同一批人。单体应用或者同一套网关体系内部HS256 足够性能也好一次 HMAC 大概几微秒。但只要出现“签发在 A 服务、验签在 B 服务、B 服务运维权限比 A 低”的情况就该换 RS256——因为 B 服务只需要公钥即使泄露也无法伪造 token。开放平台对接第三方更是必须用 RS256并把公钥通过 JWKS 端点暴露出去配合kid支持密钥轮换。这里有个性能数据可以参考在我的压测环境里4 核 8GJava 17HS256 每秒能验约 40 万次RS2562048 位每秒约 1.2 万次。QPS 不高的业务完全够用高并发场景下再考虑把公钥缓存到本地、或对频繁调用的服务改用内部签发的短时效 token。2.3 与算法相关的几个经典坑第一类是algnone。这是 JWT 规范里允许的“无签名”模式某些库的默认配置会接受它。如果服务端验签时只按 header 里的alg走攻击者把算法改成 none、删掉签名段就能直接伪造任意身份。防御办法很粗暴验签时显式指定允许的算法白名单任何不在白名单里的算法一律拒绝。第二类是HS256 与 RS256 混淆。服务端用 RS256 的公钥验签但如果代码允许根据 header 动态选算法攻击者就伪造alg: HS256然后用那串公开的公钥当 HMAC 密钥去算签名。因为公钥是公开的攻击者能算出签名服务端又能验过去。防御方式和上一条一样固定算法不接受 header 里的算法声明。第三类是kid 参数注入。kid用来指定用哪把密钥如果实现里把它直接拼进文件路径或 SQL就会变成任意文件读取或注入。kid必须做白名单映射用 ID 查表拿密钥别拼字符串。一句话总结验签逻辑里任何来自 token 本身的“元信息”都不能影响验证过程。3. 从零实现签发、校验、刷新三步走理论说完了动手写。这一段用 JavaSpring Boot jjwt和 Node.js 各写一份两条技术栈的思路完全一样。3.1 密钥准备别再用那串 hello world 了HS256 的密钥长度至少要 256 位也就是 32 字节。很多人是从教程里抄一段secret字符串直接用的长度不够、熵也低属于能被离线爆破的级别。正确做法是用安全随机数生成# 生成 32 字节随机密钥并 Base64 编码写进配置中心 openssl rand -base64 32生成后的密钥要放在环境变量或配置中心里不要硬编码进代码仓库也不要和数据库密码、第三方密钥共用一份。上线前做一次全局搜索把secret、jwtSecret、signKey这类变量挨个确认一遍我见过至少三个项目在这里留了默认值。3.2 签发Claim 怎么设计过期时间怎么定Java 侧的签发代码核心就是构建 Claims 加签名// 密钥来自配置至少 32 字节 SecretKey key Keys.hmacShaKeyFor( Base64.getDecoder().decode(jwtProperties.getSecret())); Date now new Date(); Date exp new Date(now.getTime() 30 * 60 * 1000L); // 30 分钟 String token Jwts.builder() .setSubject(String.valueOf(userId)) // 谁 .claim(role, role) // 角色越少越好 .setId(UUID.randomUUID().toString()) // jti用于注销与防重放 .setIssuer(api.example.com) // 谁签发的 .setIssuedAt(now) .setExpiration(exp) .signWith(key, SignatureAlgorithm.HS256) .compact();几个参数我解释一下为什么这么设。sub用用户 ID 而不是用户名因为 ID 不会变role只放粗粒度角色细粒度权限每次从缓存查避免权限调整后 token 里还是旧权限jti必带它是后面做注销和 refresh token 防重放的基础。过期时间上访问令牌定 15 到 30 分钟是行业里比较稳的区间。太短会导致频繁刷新用户体验差太长则等于把撤销窗口拉大。如果是内部管理后台、对安全要求一般可以放宽到 2 小时。没有绝对标准但超过 24 小时的访问令牌我建议你重新想想是不是方案本身有问题。Node.js 侧的写法差异不大但有一处必须注意const jwt require(jsonwebtoken); const token jwt.sign( { sub: String(user.id), role: user.role }, SECRET, { algorithm: HS256, expiresIn: 30m, jwtid: uuid(), issuer: api.example.com } ); // 验签时必须显式声明 algorithms const payload jwt.verify(token, SECRET, { algorithms: [HS256], issuer: api.example.com, clockTolerance: 60 });jsonwebtoken的verify如果不传algorithms老版本会接受任意算法这就是前面说的混淆漏洞的温床。养成习惯验签处永远带上算法白名单。3.3 校验错误要分类处理别一律 401验签的代码只有一行但异常处理决定了前端能不能写出正确的刷新逻辑。把所有异常都吞掉返回 401前端就无法区分“token 过期该刷新”和“签名错误该重新登录”结果就是无限刷新循环。try { JwsClaims jws Jwts.parserBuilder() .setSigningKey(key) .requireIssuer(api.example.com) .setAllowedClockSkewSeconds(60) .build() .parseClaimsJws(token); String userId jws.getBody().getSubject(); String jti jws.getBody().getId(); // 注销校验jti 是否在 Redis 黑名单里 if (redis.hasKey(jwt:blacklist: jti)) { throw new UnauthorizedException(TOKEN_REVOKED); } // 写入上下文 SecurityContextHolder.getContext() .setAuthentication(new JwtAuthenticationToken(userId, role)); } catch (ExpiredJwtException e) { throw new UnauthorizedException(TOKEN_EXPIRED); // 前端据此触发刷新 } catch (SignatureException | MalformedJwtException e) { throw new UnauthorizedException(TOKEN_INVALID); // 直接跳登录 } catch (UnsupportedJwtException | IllegalArgumentException e) { throw new UnauthorizedException(TOKEN_UNSUPPORTED); }setAllowedClockSkewSeconds(60)是给多台服务器之间时钟不同步留的容错窗口。集群里 NTP 同步偶尔有偏差1 到 2 分钟的偏移很常见不给容错会出现“刚签发的 token 就被判过期”。但也不要给太大300 秒以上的容错窗口基本等于人为延长了 token 寿命。3.4 双 Token 续签jwt 实现 token 续签的可行做法访问令牌只有 30 分钟用户不可能每半小时登录一次所以要有续签机制。主流的三种做法我都试过单 token 滑动过期每次请求检查剩余时间小于阈值就签发新 token 放在响应头里。实现简单但缺点是 token 可以无限续下去一次泄露就是长期可用而且并发请求会收到多个新 token前端处理起来很别扭。双 tokenaccess refreshaccess 短、refresh 长access 过期时用 refresh 换新的。这是目前最通用的方案推荐。服务端会话兜底JWT 只做传输载体有效期状态还是存 Redis。安全但失去了无状态的意义只适合有强撤销需求的系统。双 token 的落地细节比看上去多。refresh token 建议有效期 7 到 14 天存 Redis 时以jti为 keyvalue 记录用户 ID、签发时间、设备信息、是否已使用。换新时执行一次性和轮换public TokenPair refresh(String refreshToken) { Claims claims parseAndVerify(refreshToken); // 同样要验签 String jti claims.getId(); String userId claims.getSubject(); String key jwt:refresh: userId : jti; String status redis.opsForValue().get(key); if (status null) { throw new UnauthorizedException(REFRESH_NOT_FOUND); } // 已使用过 有可能是重放直接把该用户所有 refresh 失效 if (USED.equals(status)) { revokeAllRefreshTokens(userId); throw new UnauthorizedException(REFRESH_REUSED); } redis.opsForValue().set(key, USED, 2, TimeUnit.MINUTES); return issueTokenPair(userId); // 同时下发新的 access 和 refresh }这段逻辑里的“重放检测”值得多说一句。正常用户不会拿同一个 refresh token 换两次如果出现大概率是 token 被窃取后攻击者和用户同时在用。这时候把该用户的所有 refresh 全部作废、强制重新登录是最稳妥的处理。代价是用户可能被踢一次但比账号被长期占用要好得多。另外提醒一个前端侧的细节刷新接口一定要做并发控制。页面同时发五个请求、五个都收到 401、五个都去刷新第一个把 refresh 用掉后后面四个全部失败用户直接被登出。正确做法是在前端加一个单飞single-flight锁第一个请求触发刷新其余请求排队等结果。4. SPA 项目里的登录闭环验证码 JWT 落地前后端分离项目里JWT 很少单独出现它总是和验证码、跨域、存储策略绑在一起。这一节按一个完整登录流程走一遍。4.1 图形验证码的生成与存储设计验证码的目的防的是脚本批量刷登录接口所以它天然需要服务端状态这点和 JWT 的无状态是互补而非冲突。我的常规设计是前端调/captcha接口服务端生成 4 位字符或一道算术题服务端生成一个captchaIdUUID把答案写进 Rediskey captcha:{captchaId}TTL 设 5 分钟返回captchaId和图片Base64 或图片流登录请求带上captchaId和用户输入的验证码服务端取出答案比对不管对错都立刻删除这个 key防止爆破校验通过后再走用户名密码校验最后签发 JWT。第 5 步的“立刻删除”很关键。如果验证码答对后不删攻击者可以拿同一个captchaId反复调用登录接口验证码这道防线就形同虚设。同理登录失败次数要单独计数按 IP 或账号做限流连续失败 5 次锁 10 分钟。4.2 前后端联调的几个硬约定接口约定要提前定死否则联调阶段会浪费大量时间扯皮。我一般在接口文档里写清这几条请求头固定用Authorization: Bearer token注意 Bearer 后面有一个空格大小写敏感的问题在部分网关上会踩401 响应体里带业务码比如{code:TOKEN_EXPIRED}和{code:TOKEN_INVALID}前者前端触发刷新后者直接跳登录刷新接口用POST /auth/refreshrefresh token 通过 Cookie 传递不再放进请求体所有时间字段统一用秒级时间戳别混用毫秒。关于时间戳单位这件事我吃过一次亏后端签发时用了毫秒网关的验签组件按秒解析结果算出来的 exp 大了一千倍token 变成了“永不失效”。这种问题在本地测不出来只有跨组件时才暴露所以约定里必须写死。4.3 存储位置access 放内存refresh 放 Cookie这是我最想强调的一条。很多教程把 token 存 localStorage方便是方便但只要页面有一个 XSS 点攻击者一句localStorage.getItem(token)就能把凭证带走而且是长期有效的凭证。我的做法是access token 只放在内存里Vue 的 Pinia、React 的 Context 或模块级变量刷新页面就丢靠 refresh 重新获取refresh token 放 HttpOnly Secure SameSiteLax 的 CookieJS 读不到XSS 拿不走配了 Cookie 就要考虑 CSRFSameSiteLax 能挡掉大部分跨站请求跨域场景需要配合 CSRF Token 或校验 Origin。走 Cookie 方案时还有一个常见的联调坑跨域请求必须带withCredentials: true服务端的 CORS 配置里Access-Control-Allow-Origin不能用*必须回显具体域名同时开启Access-Control-Allow-Credentials。这三条缺一条Cookie 就不会被带上表现为“登录成功但一下又变成未登录”。5. 上线前必须过的几道坎漏洞清单与加固动作功能能跑通只是及格线安全加固才是上线前的重点。下面这份清单是我在几次安全评审里攒下来的逐条对照检查一遍能挡掉绝大多数低级问题。5.1 常见 JWT 漏洞与防护对照漏洞类型触发条件加固动作algnone 绕过验签时信任 header 里的算法服务端固定算法不读 header 的 alg算法混淆RS/HS允许动态选算法公钥被当 HMAC 密钥硬编码算法白名单签发与验签算法分离弱密钥爆破密钥短、有规律、用默认值随机生成 32 字节以上定期轮换kid 注入kid 被拼进路径或 SQL白名单映射只做 ID 查表不校验 exp只解码不验签或手动解析使用库的完整校验开启过期与生效时间检查敏感信息泄露手机号、身份证写进 PayloadPayload 只放 ID 与角色敏感信息一律不放无法撤销退出登录只清前端服务端黑名单Redis jtiTTL 等于剩余有效期重放攻击token 被截获后可重复使用短时效 jti 一次性 refresh 轮换XSS 窃取凭证token 存 localStorage改内存 HttpOnly Cookie 方案表里每一条我都在真实项目或安全扫描报告里见过至少一次其中“弱密钥”和“不校验 exp”出现频率最高原因都是照抄教程代码。改起来其实很快难的是养成习惯。5.2 参数配置基线照着填就行如果团队没有明确规范可以直接参照下面这份基线我把它写进了公司的开发手册配置项推荐值说明HS256 密钥长度≥ 32 字节随机用 openssl rand 生成RS256 密钥长度≥ 2048 位配合 JWKS 与 kid 轮换access token 有效期15 - 30 分钟后台类系统可放宽到 2 小时refresh token 有效期7 - 14 天按业务安全等级调整时钟容错窗口60 - 120 秒集群时钟同步不良时可放宽到 180 秒token 体积控制在 1KB 以内避免网关请求头超限注销黑名单 TTL等于 token 剩余有效期过期自动清理不会无限增长登录失败锁定5 次 / 10 分钟按账号 IP 双维度计数key 轮换这件事常被忽略。做法是给每把密钥分配一个kid服务端同时保留新旧两把用于验签、只用最新一把签发等旧 token 全部自然过期后再移除旧密钥。整个切换过程用户无感知不需要强制重新登录。5.3 注销与失效JWT 无状态的代价怎么补JWT 天然不支持即时失效这是无状态换来的代价。实际业务里“退出登录”“管理员踢人”“修改密码后旧会话失效”都是硬需求所以必须有兜底方案。我的常规做法是短期黑名单注销时把该 token 的jti写入 RedisTTL 设为该 token 的剩余有效期验签时查一次。因为黑名单里的条目会随 token 自然过期而淘汰存储规模可控。修改密码的场景更进一步把用户的pwdVersion写进 Payload改密码时递增版本号验签时比对不一致就判定失效。这样不用遍历该用户所有的 jti也能一次性让旧 token 全部作废。代价是每次验签要多一次缓存查询实测下来对 QPS 几千的系统完全没有压力。6. 常见问题与排查技巧实录这一节是我平时帮同事定位问题时攒的速查表按“现象 → 定位手段 → 处理”整理遇到问题可以直接翻。6.1 问题速查表现象大概率原因处理方式SignatureException密钥不一致、时钟无关对比签发与验签的密钥来源注意 Base64 解码是否一致本地验签通过线上失败多实例密钥不同、配置未同步统一从配置中心读取检查环境变量覆盖token 刚签发就判过期时间戳单位混用秒 / 毫秒统一用秒解析后打印 exp 与当前时间对比偶发过期报错集群时钟偏移开启 clockTolerance 60 秒并校准 NTP401 后无限刷新前端无法区分错误类型响应体带业务码仅 TOKEN_EXPIRED 才刷新刷新接口 401 且被登出并发刷新导致 refresh 被重复使用前端加单飞锁后端轮换加宽限窗口请求头里 token 丢失网关或反向代理丢弃超长 header压缩 Payload 体积检查代理的 header 大小限制跨域下 Cookie 不发送SameSite 或 CORS 配置不当开启 credentialsOrigin 回显具体域名中文内容验签失败编码不一致UTF-8 / GBK序列化统一指定 UTF-8部分用户一直登录失效用户设备时间偏差过大客户端不做时间判断以服务端为准6.2 排查手法三步定位到根因遇到 JWT 相关问题我固定按这三步走基本五分钟内能定位。第一步先解码看内容。把 token 的三段分别 Base64Url 解码看 Header 里的 alg 是不是你预期的算法、Payload 里的 exp 和 iat 差多少、iss 对不对。注意别把生产环境的 token 贴到第三方在线工具上本地起个脚本解就行。第二步本地复算签名。用和服务端相同的方法对前两段算一次摘要和第三段对比。不一致说明密钥或算法对不上一致但服务端仍报错那问题在服务端的验签配置而不是 token 本身。第三步打印时间线日志。在签发处、网关处、业务验签处各打一条日志记录本地时间戳和 token 的 exp、iat。三处时间一对比是不是时钟问题、是不是被中间组件改写一眼就能看出来。这个日志在排查疑难问题时价值极高建议直接常驻在 DEBUG 级别下。6.3 几条用教训换来的经验别把权限清单塞进 token。我接手过一个项目Payload 里带了 60 多条权限码token 有 4KB。结果一是网关偶发截断二是运营改了权限后要等 token 过期才生效客服天天被投诉。改成只放角色码、权限每次查缓存后两个问题一起消失。refresh token 一定要轮换。早期为了图省事refresh token 有效期 30 天且不轮换等于给了攻击者一个月的长期通行证。改成一次性使用加轮换后哪怕泄露也能在下次刷新时被检测到。logout 必须服务端参与。只清前端存储的“退出登录”是假退出测试同学随手一测就能发现。加上 jti 黑名单之后才算真正闭环。给验签失败率加监控。这个指标平时应该接近于零一旦出现尖刺往往意味着有人在批量试签名或者密钥轮换出了事故。我在一次密钥更新事故里就是靠这个告警在十分钟内发现的否则要等到用户投诉才知道。写单元测试覆盖异常分支。过期、篡改、算法不符、jti 命中黑名单这四种情况各写一个测试用例。这类代码平时不会执行出了问题是致命的用测试锁住行为最省心。密钥轮换提前演练一遍。双密钥并存的窗口期、JWKS 端点的缓存时间、旧 token 的自然过期时间这三件事的时序要提前算清楚。我见过轮换时直接覆盖旧密钥导致所有在线用户瞬间被登出的案例那场面很难看。最后说个后续可以扩展的方向如果系统开始拆微服务可以把 JWT 的签发集中到一个认证服务里其他服务只拿公钥验签配合网关统一做黑名单校验。这样每个业务服务不用再关心密钥和黑名单职责清晰密钥轮换也只需要动一个地方。