拓冰建站拓冰建站
首页 / 资讯中心 / 正文

JWT安全实践指南:从结构原理到漏洞攻防与加固落地

1. 为什么JWT安全值得单独写一篇文章1.1 先看两个真实事故我先把话说在前头JWTJSON Web Token这几年几乎成了后端鉴权的默认答案Spring Security整合JWT、SPA项目里用token做登录态、网关层统一校验身份到处都是它的身影。但正因为用得广被打穿的案例也特别多。2023年有个让我印象深刻的漏洞某开源注册配置中心组件对应CNVD-2023-17316存在默认密钥身份认证绕过问题攻击者拿着内置的JWT默认签名密钥直接伪造身份标识连交互都不用交互就绕过了管理端的认证。这类问题不是个例很多团队把JWT当成了用了就安全的银弹结果密钥明文写在代码里、算法不校验、过期时间设成一年等于把门锁挂在纸糊的墙上。另一个高频事故是算法混淆攻击。攻击者把token的header里alg字段改成none或者把RS256改成HS256不少老代码没做算法白名单校验直接照着header里的算法去验签于是攻击者随便编一个token就通过了校验。这两种事故告诉我们一件事JWT本身只是个协议规范安全问题几乎都出在使用环节和实现细节上。这文章我就打算从结构、发包格式、常见漏洞、落地实现、问题排查五个维度展开把JWT从签发到验签再到续签的每个环节都过一遍。内容偏实战后端开发、安全测试、架构评审的同事都可以直接参考。1.2 JWT的安全边界到底在哪先说一个很多人搞错的认知JWT不等于加密。JWT的payload默认只是做了Base64URL编码任何人拿到token都能解码看到里面的内容。签名只是保证内容没被篡改并不能保证内容不被偷看。所以安全边界可以压缩成三句话签名防篡改HTTPS防窃听密钥保真实任何一环松动整个鉴权链条就会塌。举个例子你就算签名算法选得再强如果密钥是个写在代码里的硬编码字符串secret123攻击者只要能拿到源码或者猜到密钥就能自己签一个合法token想冒充谁就冒充谁。反过来如果你的payload里放了用户的手机号、身份证就算签名没被破解token在传输过程中被日志记录、被浏览器插件截获解码出来就是明文泄露。还有一个本质区别需要理解传统Session方案里服务端session是存在内存或Redis里的服务端可以随时删除一个session让用户强制下线。但JWT是无状态的token一旦签发出去在它过期之前服务端没有任何办法主动让它失效。这个特性让JWT天然适合分布式水平扩展不需要共享session存储但也意味着提前吊销强制下线踢人这些操作全都得靠额外手段弥补。你设计JWT方案的时候必须带着这个认知去做权衡。2. JWT结构与发包格式拆解2.1 三段结构的逐字节拆解JWT长什么样先看一个真实tokeneyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiaWF0IjoxNTE2MjM5MDIyfQ.SflKxwRJSMeKKF2QT4fwpMeJf36POk6yJV_adQssw5c它由两段点号分成三个部分Header、Payload、Signature。三个部分各自独立做Base64URL编码然后拼接成header.payload.signature。Header和Payload都是JSON对象。Header里常见的字段是alg签名算法和typ类型通常固定为JWT。Payload里是各种声明claims比如sub主题、exp过期时间、iat签发时间、aud受众、iss签发者。Signature则是用Header里声明的算法把前两段加上密钥一起算出来的签名结果。这里有一个编码细节必须强调JWT用的是Base64URL不是标准Base64。标准Base64的字符集里有和/放到URL里会被当成空格和路径分隔符而且标准Base64还有填充字符。Base64URL把换成-把/换成_并且去掉末尾的填充。你手写解析的时候如果用了标准Base64去解码要么报错要么解出乱码。Python和Java的标准库里都有专门的Base64URL编码器别自己拼字符串去解。签名的生成过程以HS256为例就四步把Header用Base64URL编码把Payload用Base64URL编码用HMAC-SHA256(base64url(header) . base64url(payload), secret)算出摘要把摘要再做一次Base64URL编码得到Signature验签时就是把前两段重新拼起来再用密钥算一遍签名和token里的第三段比对。比对的时候要用常量时间比较函数防止时序攻击这点标准库基本都处理了但你自己写演示代码的时候要注意别用普通字符串equals。2.2 关键声明字段与常见的危险配置Payload里的声明字段有的非常安全有的埋着雷。我按重要性逐个说。alg这是攻击者最常动的字段。服务端解析时必须把它当不可信输入绝不能直接信任它然后去执行对应的验签逻辑。安全做法是代码里写死算法白名单比如只允许RS256或者只允许HS256解析时先检查这个值。kidKey ID用于告诉服务端用哪把密钥来验签。这字段看起来人畜无害但如果服务端把它拼进文件路径或者SQL查询里就成了注入点。后面讲漏洞的时候我会专门展开。exp过期时间Unix时间戳秒。签发的时候必须设置验签的时候必须校验。有些团队图省事不设过期时间结果token永久有效一个被泄露的token就能用到天荒地老。nbf生效时间表示在这个时间之前token无效。分布式环境下各个服务时钟不一定完全一致如果设置了nbf建议留出一两分钟的时钟偏移容忍量否则会出现客户端拿到的token还没生效的诡异问题。iat签发时间主要用于审计和计算剩余有效期。jtiJWT ID唯一标识。这个字段在实现token吊销和防重放时特别有用服务端可以把已使用的jti放到Redis里做短期缓存有效期内同一个token重复使用就拒绝。sub/iss/aud分别表示主体、签发者、受众。多服务场景下强烈建议校验iss和aud防止一个服务签发的token被拿到另一个服务里冒用。2.3 一个Token在请求里到底怎么发JWT的HTTP标准携带方式是放在Authorization请求头里格式是Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...Bearer后面跟一个空格然后是完整的token字符串。为什么主流方案都推荐放Header而不是放URL参数因为URL会进Web服务器日志、代理日志、浏览器历史记录token一旦进了日志等于把钥匙丢在了大街上。Header相对干净虽然也有反向代理记录Header的情况但可控性高得多。实际项目里也有用Cookie存token的尤其是SPA应用配合HttpOnlyCookie可以降低XSS窃取token的风险。但Cookie方案必须面对CSRF的困扰。如果token在AuthorizationHeader里CSRF攻击者没有跨域读取Header的能力天然免疫这类问题如果token在Cookie里浏览器会自动带上攻击者可以诱导用户发起请求来借用用户身份就必须额外加CSRF Token或者SameSite策略。我见过不少项目在Cookie模式下忘记加CSRF防护这是典型的权衡漏洞。再补充一个细节JWT本身不是只有Header一种传输方式。WebSocket握手阶段通过Sec-WebSocket-Protocol子协议传token、gRPC里通过metadata传authorization也是常见做法。但不管怎么传核心原则只有一条token只走安全通道只存在受控客户端。顺带说一下安全测试人员最关心的发包格式。如果你在Burp Suite里测试拿到一个token后想自己改payload再发包可以直接把Header和Payload解出来修改后重新Base64URL编码但别急着改签名——除非你拿到了密钥否则改了签名肯定验不过。如果只是想观察服务端对token格式错误的反应可以把签名部分随便改几个字符看它返回401还是500这能帮你判断异常处理是否规范。3. 常见JWT漏洞与攻击手法复现3.1 算法混淆攻击从algnone到公钥当密钥算法混淆是JWT最经典的一类漏洞攻击思路简单粗暴篡改Header里的alg字段让服务端用攻击者可控的方式去验签。第一种是alg: none。有些老版本库在实现时看到alg为none就直接跳过签名校验token第三段甚至可以不存在。攻击者把alg改成nonepayload改成自己要的角色比如{sub:admin,role:administrator,exp:9999999999}然后去掉签名段构造出header.payload.这样的畸形token。如果服务端没有做算法白名单直接信任alg字段管理员身份就这么被伪造出来了。第二种是RS256到HS256的混淆。RS256是RSA非对称签名用私钥签、公钥验HS256是对称HMAC签发和验签用同一个密钥。问题在于很多服务把RSA公钥公开比如放在JWKS端点验签时读取token里的alg。如果攻击者把alg改成HS256有些实现就会拿公钥的字节内容当作HMAC的密钥来做验签。而公钥是公开的攻击者拿到公钥后用HS256算法、以公钥内容为密钥就能给自己签发合法token。修复方法还是那句服务端验签算法必须写死白名单不允许客户端指定。实测中这种攻击在自研验签逻辑的代码里特别常见。标准库一般会在解析时暴露parserBuilder().setAlgorithmWhitelist(...)之类的接口你只要跳过不配就等于把算法选择权交给了攻击者。3.2 kid参数注入当Key ID变成任意文件读取kid字段的作用是告诉服务端用哪把密钥验签那服务端自然就需要找到这把密钥。很多实现是拿kid拼一个路径从文件系统或配置中心读取代码大概长这样String keyId jwtHeader.get(kid).toString(); String keyPath /etc/jwt_keys/ keyId .pem; byte[] keyBytes Files.readAllBytes(Paths.get(keyPath));这段代码在安全视角下全是洞。kid是攻击者可控的如果把kid设为../../../../etc/passwd服务端就会去读系统文件然后拿着系统文件内容去当验签密钥。更常见的骚操作是设kid为/dev/null/dev/null的内容是空字节如果你用的HMAC算法一个空密钥就产生了攻击者用空密钥给token签名服务端用空密钥验签一验一个准。如果你在代码评审里看到kid被拼进路径、SQL、或者任何命令里必须当场打回。正确做法是kid只用来做索引查询比如从数据库里查出一个之前注册好的密钥记录查询时用参数绑定而不是字符串拼接。3.3 硬编码密钥与默认密钥nacos默认密钥绕过案例开头提到的那个开源配置中心组件为什么会被默认密钥打穿我把过程简化描述一下。该组件在早期版本里内置了一个固定的签名密钥用于对其身份标识字段做JWT签名。组件文档和源码都公开密钥等于公开给全世界。于是攻击者只需要做两步第一用已知的默认密钥自行构造一个包含管理员身份信息的JWT第二把这个JWT放进请求里发给管理端。组件验签通过身份认证被绕过。这个漏洞被收录为CNVD-2023-17316之后官方给出的修复方案是升级版本改用用户自定义的强密钥并且提供密钥配置入口。但从这个案例能学到的不只是升级到最新版这么简单更重要的是自查习惯。我每次做项目安全评审都会先全局搜一下代码里有没有类似这样的硬编码密钥private static final String JWT_SECRET nacos2023; private static final String JWT_SECRET secret; private static final String JWT_SECRET 123456;如果搜得到不管是测试代码还是生产代码都按漏洞处理。硬编码密钥的危害在于代码会进入Git仓库、会同步到同事电脑、会被CI日志打印、会被外包看到泄露面完全不可控。正确做法是用环境变量或配置中心管理密钥并且做到环境隔离测试环境一套、生产环境一套。另外密钥轮换这件事很多人会忽略。密钥不轮换时间越长泄露风险越大。建议设计的时候就把kid用起来一个密钥对应一个kid轮换时新旧密钥并行一段时间等旧token自然过期后再摘掉旧密钥这样能实现不踢用户的无感知轮换。3.4 敏感信息泄露与重放攻击JWT的Payload是明文这个特点放在安全机制完全指南里值得单独划重点。我见过一个真实项目登录成功后把用户的完整手机号、邮箱、还有内部用户ID全部塞进payload理由是前端要用这些字段渲染页面。结果前端页面有XSS漏洞token被脚本偷走攻击者解码token直接拿到了用户手机号这属于可以写进事故报告的失误。重放攻击则是另一个维度的问题。假设token过期时间是两个小时攻击者在这两个小时内截获了请求他不改token只是把原始请求原样再发一遍。服务端怎么知道这是攻击者发的答案是不知道。JWT无状态服务端不做记录就拦不住原样重放。缓解手段包括缩短access token的过期时间、给token加jti并搭配短期缓存做使用记录、关键操作转账、改密额外做一次性校验码。别幻想一个JWT能解决所有鉴权问题有些场景你就是需要配合其他机制一起用。4. JWT安全落地的完整实现方案4.1 主流语言JWT库选型与推荐配置不同语言的JWT库质量参差不齐我按自己实际用过的给你列一份。Java这边我推荐io.jsonwebtoken:jjwt0.11.x以上API设计比较干净算法白名单、过期校验都内置Spring Security生态里也可以直接用spring-security-oauth2-jose里的NimbusJwtDecoder和框架整合度更高。Node.js首选jsonwebtoken用的人多、出问题搜得到Python推荐PyJWT轻量且支持算法白名单Go的话golang-jwt/jwt是社区维护最活跃的。选库的时候最该看的三件事是否支持算法白名单、是否默认校验过期时间、是否提供常量时间比较。我个人踩过一次坑某个库版本较老解析token时需要手动调用setExpiration校验过期时间我漏了这一步结果过期token全部放行排查半天才定位到。参数配置上我给出自己的推荐值供参考配置项推荐值说明签名算法RS256优先或 HS256多服务间用RS256便于分发公钥单体可用HS256但要保证密钥强度AccessToken有效期15~30分钟太短体验差太长泄露风险大RefreshToken有效期7~30天配合续签机制允许用户长时间免登录时钟偏移容忍60秒防止多机时钟不一致导致误判过期密钥长度HS256至少32字节RS256至少2048位密钥太短就是给攻击者送分必须校验字段exp、iss、aud三者全部校验才叫完整的验签4.2 SPA项目中的验证码与JWT登录整合SPA项目里常见的登录流程是账号密码加验证码验证码用JWT做登录态管理。这里有一个很自然的整合方式我把完整流程拆给你看。第一步前端打开登录页向后端请求验证码。后端生成图片验证码把正确答案存在Redis里key用captcha:{uuid}value就是验证码答案同时设置5分钟过期。注意这里必须用uuid关联不能把验证码塞进JWT——验证码是一次性的而JWT过期时间以分钟甚至小时计两者生命周期不匹配。第二步用户输入账号、密码、验证码、uuid一起提交到登录接口。后端先拿uuid去Redis取验证码校验通过后删除该key一次性使用然后再查账号密码。验证码校验不通过的服务端会返回401并拒绝继续处理这样能避免对密码字段做无意义的数据库查询。第三步账号密码正确后签发两个JWT。AccessToken有效期15分钟RefreshToken有效期7天。Response返回这两个token同时可以把用户基本信息不要放手机号等敏感字段放AccessToken的payload里前端解码后用于页面渲染。第四步前端把AccessToken放在内存或sessionStorage里放localStorage要谨慎XSS风险更高每次请求用axios拦截器统一注入Authorization: Bearer头。RefreshToken建议放HttpOnlyCookie里路径限定在刷新接口。当接口返回401时前端拦截器自动调用刷新接口换新AccessToken用户无感知续期。这个方案的取舍点在于验证码用Redis存储服务端多了一次状态依赖但验证码本来就是一次性交互用Redis管理生命周期反而比塞进JWT更合理。JWT负责的是登录后的长效身份各管一摊职责清晰。4.3 Token续签双Token刷新机制完整实现JWT续签这件事我见过太多人直接改exp重新签一个甚至有人把AccessToken有效期设成30天来逃避续签。这两种做法都是拿安全换省事。标准的续签方案是双Token机制。AccessToken有效期短比如15分钟用户每次请求都带上它服务端只验签和查过期不做任何持久化状态。RefreshToken有效期长比如7天专门用来换AccessToken。用户AccessToken过期后前端拿RefreshToken去调刷新接口服务端验RefreshToken的签名和有效期通过后签发一个新的AccessToken。不需要用户重新输密码体验上就是无感续期。RefreshToken要处理好三个细节。第一是保存方式RefreshToken不能扔给前端localStorage随便存建议放HttpOnly Cookie限制SameSiteLax并设置Path/refresh只让刷新接口的请求带上它。第二是轮换每次使用RefreshToken刷新成功后服务端必须签发一个新的RefreshToken同时让旧的失效。这样如果RefreshToken被偷攻击者用一次、服务端轮换一次、用户下一次刷新时发现旧RefreshToken失效就能感知到有人冒用从而实现失窃检测。第三是吊销RefreshToken在Redis里存一份key是它的jtivalue是用户ID用户主动退出时删掉这个keyRefreshToken立即失效。刷新接口要防一个经典漏洞重放刷新请求。攻击者只要拿到一个RefreshToken在它过期之前能反复刷新换新token相当于永久登录。配合RefreshToken轮换和短期使用记录Redis存最近使用过的jti基本可以把重放窗口缩到很小。4.4 Spring Security JWT整合要点Java后端这些年最常用的组合就是Spring Security加JWT但很多人是一边抄配置一边踩坑。我直接给你梳理一条核心链路。Spring Security的过滤链是责任链模式JWT整合的思路是在UsernamePasswordAuthenticationFilter之前插入一个自定义过滤器职责是从请求头解析token、验签、构造Authentication对象放进SecurityContextHolder。关键代码如下public class JwtAuthenticationFilter extends OncePerRequestFilter { private final JwtTokenProvider tokenProvider; public JwtAuthenticationFilter(JwtTokenProvider tokenProvider) { this.tokenProvider tokenProvider; } Override protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain chain) throws ServletException, IOException { String token resolveToken(request); if (token ! null tokenProvider.validateToken(token)) { Authentication auth tokenProvider.getAuthentication(token); SecurityContextHolder.getContext().setAuthentication(auth); } chain.doFilter(request, response); } private String resolveToken(HttpServletRequest request) { String bearer request.getHeader(Authorization); if (bearer ! null bearer.startsWith(Bearer )) { return bearer.substring(7); } return null; } }然后把这个过滤器注册进安全配置同时把Session策略设成无状态Configuration EnableWebSecurity public class SecurityConfig { Bean public SecurityFilterChain filterChain(HttpSecurity http) throws Exception { http.csrf().disable() .sessionManagement().sessionCreationPolicy(STATELESS) .and() .authorizeHttpRequests() .requestMatchers(/api/auth/login, /api/auth/refresh, /captcha).permitAll() .anyRequest().authenticated() .and() .addFilterBefore(jwtAuthenticationFilter(), UsernamePasswordAuthenticationFilter.class); return http.build(); } }这里有几个特别容易出问题的点。第一csrf().disable()是因为我们用的是Bearer Token模式本身不受CSRF影响但如果你换成了Cookie存token这条必须重新评估否则等于自己拆了一面盾。第二addFilterBefore位置写错会导致过滤器不生效记住一定要放在UsernamePasswordAuthenticationFilter之前。第三Spring Security的异常处理要单独定义AuthenticationEntryPoint否则token过期时返回的是默认的403而不是约定的401前端拦截器就傻眼了。5. 常见问题排查与加固清单5.1 高频报错速查表JWT在生产和测试中最常出现的报错我整理成了一张表你Debug的时候可以直接对着看。报错信息常见原因排查思路SignatureException密钥不匹配或token被篡改确认签发和验签密钥是否一致检查是否更换过密钥ExpiredJwtExceptiontoken过期确认系统时间和签发时间是否一致检查时钟偏移设置MalformedJwtException格式错误检查是否为三段式是否混入了换行符或多余空格UnsupportedJwtExceptionalg不匹配或token不完整检查Header里的alg与服务端白名单是否一致IllegalArgumentException传入空token确认过滤器是否处理了缺失Authorization头的场景解析出的payload乱码Base64和Base64URL混用确认解码时用的是Base64URL刷新token一直失败RefreshToken未轮换或已注销检查Redis里的状态管理确认轮换时新旧token是否冲突时钟偏移这个坑我单独强调一下。JWT里的exp和iat都是Unix时间戳单位是秒。但有些语言的标准时间戳接口返回的是毫秒比如Java的System.currentTimeMillis()和JavaScript的Date.now()写校验逻辑前必须做一次除以1000的转换。我曾经在一次跨语言联调中Java服务端签发的token用JavaScript前端解析就是因为秒和毫秒的差异导致提前过期了排查了很久。5.2 你可能会忽略的细节除了报错还有几个细节如果你在集成阶段不处理好线上会出各种玄学问题。第一个是网关与服务间的token透传。微服务架构里网关解完token后常常会把用户信息放在Header转发给下游服务比如X-User-Id。但如果网关不加白名单过滤外部请求伪造一个X-User-Id头下游服务直接用就等于攻击者自己填了个用户ID。正确做法要么是下游继续验签原始token要么对内部透传Header做严格的来源控制。第二个是Authorization头的大小写和空格问题。标准格式是Authorization: Bearer token但有些客户端会写成bearer小写或者在Bearer前多加一个空格。过滤器解析时最好用startsWith(Bearer )配合忽略大小写去判断或者干脆直接取空格后的最后一段别做严格的相等比较。第三个是密钥强度和随机性。很多人用UUID当密钥UUID本身是16进制的字符集有限随机性也没有专门生成的密钥强。建议用openssl rand -base64 48生成密钥至少48字节然后放到配置中心管理。别自己拍脑袋编字符串人类编的密码在暴力破解面前不值一提。5.3 安全加固检查清单每次JWT模块上线前我建议按这个清单自查一遍这也是我在项目里使用的检查表签名算法是否为白名单固定有没有信任token里的alg字段密钥是否通过环境变量/配置中心注入代码和仓库里有没有硬编码密钥密钥强度是否达标HS256密钥不低于32字节RefreshToken是否做了轮换和短期使用记录是否校验了exp、iss、aud三个关键声明payload里有没有放身份证、手机号等敏感明文token存放位置是否避免lodged localStorage提醒自己如果确实用localStorage必须有缓解措施有没有对关键操作额外增加一次性校验或二次验证过期token是否统一返回401并规范了异常响应多服务场景下是否校验了token签发者和受众防止跨服务冒用这十条我至少帮同事复现过其中六条对应的线上问题。每次多花十分钟检查省下来的都是凌晨的报警电话。最后再分享一个我自己养成的小习惯新项目里JWT模块写完我会顺手用攻击者视角构造三组测试token一组algnone、一组改了kid为/dev/null、一组改签名乱码直接打到自己服务上。如果这三组里有任何一组通过了验签这个服务绝不能上生产。这个自测动作成本极低但每次都能发现点意外之喜。你写JWT相关的代码时也不妨试试。
分享:

看完干货,该让你的企业上线了

免费需求沟通 · 48 小时内出具建站方案 · 河南本地可上门