1. JWT的Header、Payload、Signature到底怎么拼很多人一上来就背JWT三段式结构知道是Header.Payload.Signature但真到了排查问题的时候自己手写个Base64解码就开始懵。我觉得先把这三段彻底拆开讲透后面所有关于漏洞、续签、整合的讨论才立得住。1.1 Header里藏着什么Header是一个JSON对象常规长这样{ alg: HS256, typ: JWT }typ一般固定是JWT提示这是标准JWT数据alg是签名算法我遇到过不少项目在开发环境里图省事把alg直接设成none这可能直接就把认证绕过的大门打开。实际生产里alg主要是HS256/HS512对称算法同一个secret加签验签和RS256/RS384/RS512非对称算法私钥签名、公钥验签。还有一类是ES256椭圆曲线算法比较少见但也在陆续遇到。Header里有时还会出现kid字段代表Key ID用来告诉服务端“我这个Token是用哪把密钥签的”。这个字段看起来是贴心的设计却也常常成为攻击面后面我会单独开一节说。1.2 Payload能放什么、不能放什么Payload是Token携带的声明数据分三类Registered claims官方建议的公共字段比如iss签发人、sub主体、aud受众、exp过期时间、nbf生效时间、iat签发时间、jti唯一ID。Public claims业务自定义字段但为了防止和被保留字段冲突最好通过IANA注册或加上命名空间前缀。Private claims服务端和客户端约定的私有字段最常见的是userId、username、role。我见过很危险的做法是把password哈希存进Payload为了省一次DB查询这不光是Base64可解码的问题任何人都可以解码看到它虽然哈希很难逆推但哈希已经暴露给所有人等于把离线爆破的素材交出去了。Payload里的数据真的只适合放“非敏感、非机密的身份标识”要记住Payload不是加密的是裸的Base64。1.3 Signature的生成规则与验证规则签名部分跟算法绑定但不管哪种算法都逃不开这个思路signature HMACSHA256(base64UrlEncode(Header) . base64UrlEncode(Payload), secret)实际用的不是普通Base64而是Base64URL编码把换成-、/换成_并去掉末尾的。验签就是服务端用同样的密钥、同样的算法重新拼一次签名比对是否一致。这里藏着最关键的一个陷阱任何JWT安全问题的本质都是服务端到底信了Token里的哪个字段。算法降级、密钥混淆、kid注入全都是围绕“验证方式可被客户端控制”这一点做文章。从工具实战角度我最常用的是jwt.io这个网页工具去做快速解码验证但如果要批量解析、或者在脚本里做爆破我一般用Python的PyJWTpip install pyjwtimport jwt token xxx.yyy.zzz # 只解码Header和Payload不验签 header jwt.get_unverified_header(token) payload jwt.decode(token, options{verify_signature: False}) print(header) print(payload)注意verify_signatureFalse只能用来排查数据结构绝对不能出现在生产验签逻辑里。用PyJWT做正确验签时HS256这样写decoded jwt.decode(token, secret, algorithms[HS256])要点提醒algorithms参数一定要显式传如果写成algorithmsNone旧版本PyJWT可能不校验算法直接按Header里的alg走——这就是算法混淆漏洞产生的根源之一。2. HS256和RS256的选择以及算法混淆攻击为什么能得手选型问题在JWT项目里永远绕不开我几乎每次评审代码都会问一句“签名算法定的哪种”有人觉得HS256语法看起来更简洁有人觉得RS256要生成密钥对更麻烦但安全性差异巨大。2.1 对称与非对称的本质区别HS256是对称算法加签和验签用的是同一个密钥。所以这个密钥必须保存在服务端不能让客户端知道。所有需要验证JWT的服务端实例都要共享同一把secret。如果系统里有多个微服务都要校验JWTsecret被任何一个服务泄露整个系统的Token都可以被伪造。RS256是非对称算法私钥负责签名只有持有私钥的服务能发Token公钥公开分发各服务只做验签。哪怕某个验签服务的公钥泄露攻击者也无法伪造签名因为他没有私钥。这也是现代微服务架构里推荐RS256的原因——验签方不需要接触私钥。2.2 算法混淆攻击的完整链路算法混淆攻击Algorithm Confusion是JWT最经典的攻击方式之一。它利用的是服务端在验签时同时支持多种算法、但没有验证算法是否与预期一致的缺陷。攻击思路是这样的拿到一个合法Token解码Header得知正常算法是RS256攻击者想办法搞到服务端的公钥公钥通常可以从认证服务的JWKS端点获取比如/.well-known/jwks.json攻击者把Header里的alg改成HS256然后用这个公钥的内容当作HS256的对称密钥重新对Header和Payload计算签名服务端如果看到algHS256也接受了RS256公钥参与HMAC验签那么攻击者就伪造成功了。这看起来非常不可思议公钥明明是公开的东西怎么能当对称密钥用但很多实现里验签逻辑是“Header说用什么算法我就用什么算法”没有校验这个Token在签发时到底该用哪种算法。当服务端误用公钥作为HMAC密钥时攻击者就成功了。防范方法只有一条验签时严格白名单算法。比如里面的签名算法实现JwtParser parser Jwts.parserBuilder() .setSigningKey(publicKey) .build();这种写法太弱了必须显式声明Jwts.parserBuilder() .setSigningKey(publicKey) .setAllowedClockSkewSeconds(60) .build();在大多数框架里正确的做法是Jwts.parserBuilder() .setSigningKey(publicKey) .setAllowedClockSkewSeconds(60) .setUnsecuredDisabled() .build();不同库的API不一样但核心就一句话只允许RS256/RS384等预定的白名单算法禁止服务端根据Header的alg字段动态选算法。2.3 实际项目里我为什么优先推荐RS256抛开攻击面不谈就从工程演进角度看一个系统早期可能只有一个认证中心HS256够用且快但当业务拆分后多个后端服务要校验Token如果全部共享同一个HS256 secret密钥管理就成了一个大问题——改密钥要所有服务同步改配置任何一个服务的配置文件泄露都会拖垮全体。RS256虽然计算开销稍大且现代CPU上完全可忽略但私钥集中在认证中心其他服务只需要信任公钥可以随时轮换密钥对对服务影响面小得多。所以只要系统存在多个服务我一般就直接用RS256省得后期重构。3. 从登录到校验无状态认证的完整调用链项目热点里提到的“JWT实现Token登录验证”和“JWT实现Token续签”都依赖这条调用链的理解。我把完整流程拆开来讲顺带解释为什么JWT适合做认证、却不适合做会话管理。3.1 无状态认证的设计动机传统Session方案里用户登录后服务端把Session ID存内存或Redis客户端存Cookie。每次请求服务端要去Redis查一下这个Session是否存在、有没有过期。这带来了两个问题服务端内存/Redis压力大分布式部署时要么引入集中存储要么配置Session同步运维复杂度上来了。JWT的思路是把用户身份信息签成Token发给客户端之后每次请求只要带上它服务端验签通过即信任内容。服务端不需要存任何会话数据——这就是“无状态”的含义。3.2 标准调用链我画一个我在项目里落地时最常描述的时序不依赖具体框架语言用户POST账号密码到/api/login认证服务校验密码成功后生成JWT一般有效期2小时到24小时之间返回给客户端客户端把Token存到localStorage或cookie前端每次请求在Authorization: Bearer token里带上后端网关或业务服务拿到Token先校验签名再校验exp然后取出userId/role等字段业务代码不再从Session拿用户信息而是从Token Claims里拿。第5步是最容易被简化、也最容易出错的地方。很多团队把验签放在业务代码的过滤器里裸写JWT解析逻辑完全没有统一的认证过滤器这会导致每个服务的实现不一致有的校验太松、有的校验太严。如果做网关层统一验签把用户信息透传给下游服务下游就不要重复解析JWT了。3.3 为什么说“无状态”是一把双刃剑JWT的好处是服务端零存储但代价是失去了“主动失效能力”。用户被踢下线、改密码、被封禁——Token在过期之前依然是合法的。我在实际项目中就处理过这个尴尬场景用户修改了密码但旧Token在2个小时内仍然有效攻击者如果已经拿到旧Token还能在这段时间内访问系统。解决这个问题单靠JWT本身做不到必须在登录时把密码版本号或用户状态版本号放进Payload每次请求到业务服务或认证服务时再去比对当前版本版本不一致就直接拒绝。这也是前面Payload设计里我提示过的敏感数据不要放但版本号、会话ID这类“锚点”数据应该放。4. 那些年踩过的JWT漏洞密钥爆破、kid注入和Nacos默认密钥这个章节是热点里“JWT漏洞总结”“JWT Kid”“Nacos默认密钥身份认证绕过漏洞CNVD-2023-17316”“JWT发包格式”的集中落点。我按攻击面一个一个讲透。4.1 密钥爆破JWT安全强度不取决于算法取决于secretHS256的密钥是普通字符串如果密钥足够长、随机性足够强爆破难度极高但如果开发者偷懒把secret写成secret、123456、jwt这种弱口令攻击者只需要收集到一个合法Token就能离线爆破。爆破工具很多我最常用的是c-jwt-cracker速度还行和hashcat配合模式规则可以跑字典。但作为开发者比爆破手段更重要的是理解一件事HS256的密钥必须暴露越少越好且绝不能入库代码仓库。有人把secret写在application.yml提交到Git这等于把安全神话打破了一半。一个可用但仍有风险的思路是用环境变量挂载密钥例如jwt: secret: ${JWT_SECRET} expiration: 7200对攻击者来说密码弱不弱看Token复杂度基本就能判断。之前有一种较常见的方式是把secret长度做得够长比如32字节以上随机数防止被字典命中。但更强的做法是干脆用RS256不存在“共享secret”的问题也就没有爆破入口。4.2 kid参数注入一个Header字段引发的命令执行kid是Header里的“Key ID”用来告诉服务端用哪把密钥。在很多开源实现里服务端会拿到kid去拼一个路径比如/keys/{kid}.pem然后用这个文件作为验签密钥。问题出在哪如果开发者直接拿kid做字符串拼接攻击者可以传../../etc/passwd让服务端加载一个攻击者可控的文件——甚至如果代码里有curl或者读取远程URL的逻辑kid可以直接指向http://evil.com/key.pem攻击者就能把自己的公钥塞给服务端让服务端“信任”攻击者签名的Token。我还见过更极端的案例开发者在验签代码里用了某种表达式引擎去动态计算kid结果被注入表达式直接执行了命令。虽然这类案例不多但足以说明Header里的参数必须当作不可信输入对待。经验做法是kid不要直接用来拼路径而是用一个Map维护kid - 密钥对象查不到就拒绝如果一定要支持多密钥轮换也要把查询逻辑限定在白名单表里不要开放“任意文件读取”。4.3 Nacos默认密钥CNVD-2023-17316复盘热点里提到的“在Nacos默认密钥身份认证绕过漏洞CNVD-2023-17316攻击者可利用默认JWT密钥伪”就属于这类问题。Nacos是一个开源的服务发现/配置中心组件它内置了一个JWT认证机制。问题在于Nacos的默认配置使用了固定的、公开的JWT密钥代码仓库里的默认secret大量用户部署后没有修改这个密钥。攻击者只需要知道目标Nacos版本默认密钥自己伪造一个JWTClaims里带username: nacos、role: ROLE_ADMIN之类的管理员字段然后把伪造的Token放进请求头就能绕过身份认证直接调用Nacos管理接口读取、篡改配置中心里的配置。如果配置中心里存有数据库连接串、中间件账号密码这基本就是把整个生产环境钥匙交了出去。这个案例带给我两个非常深的体会任何默认密钥、默认口令都是潜在的漏洞面部署文档必须要求用户强制修改JWT漏洞不只是代码级的问题也是部署运维和环境管理的问题。如果容器镜像里已经带了默认密钥即便代码写得再好部署出来也照样被绕过。4.4 发包格式从攻击者视角看JWT请求“热搜词”里有一项是“JWT发包格式”安全和开发两类人关注的点不太一样。开发关注的是正确格式比如POST /api/user/info HTTP/1.1 Host: example.com Authorization: Bearer eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOiIxMjM0NTY3ODkwIn0.longsignature Content-Type: application/json安全测试人员关注的是“怎么改包”一般流程是用Burp Suite拦截登录请求拿到合法Token把Token复制到JWT工具或手动脚本里做解码修改Payload里的userId/role/exp使用目标密钥/算法重新签名再放回Authorization头发包。如果服务端验签不到位修改后的Token就能通过校验而且你还可以测出不少问题比如删除Signature后Token仍然被接收有些实现竟然不校验签名exp改成未来时间后Token依然有效某些实现没有认真判断时间字段把alg改成none后直接通过。所以作为开发方自测JWT时一定要用攻击者的视角过一遍上述流程不要只测正常链路。很多时候我接手项目第一件事就是用这种发包方式去验证线上环境的JWT是否真的不能被篡改。5. Token续签不能拍脑袋三种续签方案的取舍“JWT实现Token续签”也是热搜里的高频词说明很多人被过期问题困扰。无状态Token的过期机制比Session麻烦得多因为它无法在服务端动态延长。我梳理了实践中三种主流续签方案。5.1 方案一Redis版本号 短Token这个方案的核心是不完全依赖JWT的exp而是在Redis里存一个“当前用户Token版本号”或者“最后过期时间戳”。具体做法登录时生成Token把jti或userId写入Redis设置过期时间和Token的exp一致每次请求验签通过后再查一次Redis确认这个jti还有效用户请求一次就刷新一次Redis的TTL只要用户持续活跃Token就不断顺延一旦Redis里的记录过期即使JWT的exp还有几分钟也直接拒绝。这个方案的优点仍然保留JWT的无状态验签优势Redis这里相当于一个轻量的“会话状态层”解决了主动失效问题实现简单不涉及刷新接口。缺点是每来一个请求都要查Redis等于部分放弃了“无状态”的初衷但换来的是可控性这个取舍我认为在多数业务场景下值得。5.2 方案二Refresh Token双Token这是业界最常见的长短Token方案也是我推荐做单页应用和移动端时使用的方案登录成功返回两个TokenAccess Token短15分钟到2小时和Refresh Token长7天到30天前端每次请求带Access TokenAccess Token过期后前端拿Refresh Token请求/api/auth/refresh换取新的Access Token顺带旋转Refresh Token旧的作废如果Refresh Token也过期前端静默跳登录页。这个方案的精华在于Refresh Token不能只做“延长”一个动作必须做“轮换”每次刷新都签发新的Refresh Token旧Refresh Token作废。这样即使Refresh Token在某次传输中被窃取一旦攻击者使用它刷新成功老Token就失效了双方会在某个时间点出现“旧Token明明有效但已被使用”的争夺痕迹。在移动端我用极简方式表示1. POST /login - { accessToken, refreshToken } 2. GET /resource with accessToken 3. 401 - POST /refresh with refreshToken - { newAccessToken, newRefreshToken }5.3 方案三滑动续期Sliding Expiration滑动续期的意思是Token的过期时间不是固定的每次有效请求到达后都往后推一段时间比如固定30分钟无操作才过期。这个方案对用户体验最好但也最危险。因为它在没有服务端状态的情况下把“永不失效”变成了可能——只要攻击者定期提前带着Token刷接口就行。所以我现在一般不会单独用纯JWT做滑动续期而是结合Redis版本号方案把滑动续期建立在Redis状态之上不让Token自己“滑动”而是让Redis里的TTL滑动这样还能控制异常。5.4 我踩过的续签坑第三次做续签功能时我掉过一个坑Refresh Token没有绑定用户只存了一个随机串。结果一个用户在两台设备上登录A设备刷新后把Refresh Token作废了B设备也被迫下线——用户以为出了Bug。后来我把Refresh Token设计成按deviceId维度区分A设备的刷新不影响B设备。这个细节比用什么算法还重要。6. Spring Security整合JWT的Filter链路与配置要点热点里明确提到了Spring Security整合JWT这也是面试和实战双高频场景。我假设你用的是Spring Boot 2.x Spring Security 5.x这种主流组合把整合链路完整梳理一遍。6.1 Filter链的位置Spring Security默认的过滤器链里UsernamePasswordAuthenticationFilter负责表单登录BasicAuthenticationFilter负责Basic认证。整合JWT的思路是插入一个自定义Filter比如叫JwtAuthenticationFilter放在UsernamePasswordAuthenticationFilter之前专门从请求头里取Token、验签、构建Authentication对象然后塞进SecurityContextHolder。代码骨架Component public class JwtAuthenticationFilter extends OncePerRequestFilter { private final JwtTokenProvider jwtTokenProvider; private final UserDetailsService userDetailsService; Override protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws ServletException, IOException { String token jwtTokenProvider.resolveToken(request); if (StringUtils.hasText(token) jwtTokenProvider.validateToken(token)) { String username jwtTokenProvider.getUsername(token); UserDetails userDetails userDetailsService.loadUserByUsername(username); UsernamePasswordAuthenticationToken authentication new UsernamePasswordAuthenticationToken(userDetails, null, userDetails.getAuthorities()); authentication.setDetails(new WebAuthenticationDetailsSource().buildDetails(request)); SecurityContextHolder.getContext().setAuthentication(authentication); } filterChain.doFilter(request, response); } }这段代码有两点必须强调验签不通过时不要在这里抛异常而是让请求继续走过滤器链最后进入AuthenticationEntryPoint去响应401。如果在Filter里直接抛异常全局异常处理可能没法覆盖返回的响应会很不规范。每次请求都把UserDetails查一遍这和上面Redis版本号的思路是配合的——如果用户被禁用或调整了角色下一次请求立即生效。代价是每一个请求都多一次DB查询通常可以接受如果接受不了可以退化成纯JWT Claims授权但要把用户状态变更的即时性权衡清楚。6.2 安全配置Configuration EnableWebSecurity public class SecurityConfig { private final JwtAuthenticationFilter jwtAuthenticationFilter; Bean public SecurityFilterChain filterChain(HttpSecurity http) throws Exception { http.csrf().disable() .sessionManagement().sessionCreationPolicy(SessionCreationPolicy.STATELESS) .and() .authorizeHttpRequests() .requestMatchers(/api/auth/**).permitAll() .requestMatchers(/api/admin/**).hasRole(ADMIN) .anyRequest().authenticated() .and() .exceptionHandling().authenticationEntryPoint(restAuthenticationEntryPoint()) .and() .addFilterBefore(jwtAuthenticationFilter, UsernamePasswordAuthenticationFilter.class); return http.build(); } }关键设置在sessionCreationPolicy。设为STATELESS后Spring Security不会再创建HttpSession这也正好契合JWT无状态认证的目标。AuthenticationEntryPoint要返回JSON 401而不是默认的重定向到/login页面否则前端拿到的是302而不是401。6.3 几个容易踩的Spring整合坑自定义Filter没有被Spring管理如果你在配置里new JwtAuthenticationFilter()而不是注入那Filter里依赖的JwtTokenProvider全是null。正确做法是把自定义Filter定义为Bean并注入到SecurityFilterChain方法里。OncePerRequestFilter和普通Filter的区别普通Filter在一个请求经多次转发时可能被重复执行OncePerRequestFilter能保证同一请求只执行一次——JWT解析这种逻辑重复执行不仅浪费性能还可能造成重复认证覆盖问题。CORS配置被Spring Security拦截很多项目里CORS没生效是因为http.cors()没开。跨域预检OPTIONS请求也会被JWT过滤器处理需要放行。7. SPA前端的Token管理与刷新配合热搜词里有一组是“SPA项目开发之JWT验证码实现”我补上前端这部分。SPA单页应用的验证码、Token存储、刷新都有自己的一套讲究。7.1 登录页验证码与JWT的关系JWT本身和验证码没有直接关系但它们在登录流程里是衔接的前端打开登录页先请求/api/captcha后端生成验证码图片并返回一个captchaId用户输入账号、密码、验证码提交到/api/login后端校验验证码通过后再校验账号密码然后签发JWT。关键点在于验证码的校验应该在JWT签发之前且验证码必须与用户名/手机号等绑定否则验证码会被多端复用攻击。普通做法是验证码存在Rediskey是captcha:login:{captchaId}值就是验证码答案5分钟过期。7.2 Axios拦截器里做Token刷新前端体验好坏很大程度在于401后的处理。我在页面里会这样设计请求发出去若返回401且当前请求不是/refresh接口用Refresh Token请求刷新接口拿到新的Access Token更新本地存储的Token重放刚刚失败的请求。一个相对可靠的伪代码思路axios.interceptors.response.use( response response, async error { const { response, config } error; if (response.status 401 !config._retry) { config._retry true; const newAccessToken await refreshAccessToken(); config.headers.Authorization Bearer ${newAccessToken}; return axios(config); } return Promise.reject(error); } );稍微注意一下如果多个请求同时401它们会各自去刷新可能导致Refresh Token被并发刷新多次、旧的被作废。所以线上要加“正在刷新中”的锁其他401请求等待同一个刷新Promise返回。这一步常常被忽略但却是很多SPA在Access Token过期瞬间出现白屏、请求风暴的根源。7.3 前端Token存储localStorage还是Cookie这个问题我回答过很多次。简单结论是如果项目对XSS防护很有信心且不需要跨域Cookie场景可以用localStorage方便前端拦截器来控制Header如果项目被XSS风险困扰或者要走httpOnly的安全路线应把Token放在httpOnlyCookie里但这样做前端JavaScript就拿不到Token刷新逻辑要么交给后端Cookie自动续期要么通过/refresh接口种新Cookie。我个人的实践经验是必须从威胁模型出发。对安全性要求较高的系统比如涉及资金、敏感数据的后台选httpOnlyCookie CSRF防护对普通业务系统localStorage 拦截器刷新已经够用。不要盲目追求“更安全”反而把项目坑进CORS和CSRF的无底洞。关于“JWT在SPA项目里的验证码实现”还有一个容易被忽略的小细节验证码接口本身不能被JWT拦截器拦截因为它发生在登录前。很多SPA项目在封装axios时全局带上Authorization头验证码请求被这个头污染后端如果严格校验就会直接403。我在项目里一般专门创建一个不带Token的axios实例给captcha用const noAuthAxios axios.create({ baseURL: /api });这个小设计省了很多次联调返工。8. 结合Nacos案例的JWT安全自查清单前面讲了漏洞原理和开发链路最后的落脚点放在“安全自查”上。因为我发现很多团队不是不知道JWT而是缺少一个可以直接照着检查的单子。我在接手项目时会按以下顺序过一遍JWT相关代码[ ] 是否支持alg: none支持就必须下线[ ] Key是否硬编码在代码/配置仓库中是则必须迁移到环境变量或密钥管理服务[ ] 是否允许多种算法同时验签是则改为白名单算法[ ]kid是否直接拼接文件路径/URL是则改为映射表[ ] Token里放了哪些敏感字段有password、身份证、手机号等就必须移除[ ] Token过期后后端有没有主动拒绝能力没有则引入Redis版本校验或双Token[ ] 刷新接口有没有做旧Token作废轮换没有则补上[ ] 登录验证码接口是否被全局JWT拦截器拦住拦住会导致登录页白屏[ ] 前端并发401时有没有做刷新加锁没有会触发请求风暴[ ] Nacos/网关等中间组件的JWT密钥是否改掉了默认值没改可能导致绕过漏洞。每个“否”都是一个可以立即推进的修复项。我在规整自己项目的过程中也是拿着这套清单一个个打钩。安全问题往往不是某一个代码写错了而是一连串配置、假设、疏忽叠加出来的结果。JWT相对其他认证方案本身不算复杂复杂的是把“验签、时效、失效、刷新、存储”每一环都处理到位。做了这么多JWT相关的项目之后我的体会是技术选型通常不是最大风险最大的风险是对默认配置和安全假设的盲目信任。Nacos那个漏洞也好算法混淆也好本质上都是“默认值被信任”吃到了苦头。如果读完这篇文章你只记住一件事那我希望是所有默认配置都要质疑一遍所有Header里的字段都要当用户输入处理——这两条能挡住绝大多数JWT方向的真实攻击。