ARTICLE DETAIL

资讯详情

深耕编程入门与网站建设的一线实战洞察。

JWT安全机制全解析:密钥管理与防绕过实战指南

JWT安全机制全解析:密钥管理与防绕过实战指南 有次线上事故让我印象特别深某个老系统突然出现大量伪造身份的请求排查后发现服务端验签时用的JWT密钥写死在代码里而这个密钥在几年前的Git历史里就已经泄露。攻击者拿这个密钥直接签发了管理员身份Token绕过登录调内部接口。那次之后我把JWT安全机制当成一等大事而不是写完签发和校验就收工。这篇指南会把JWT的组成、安全防线、核心细节、完整实现、常见漏洞和排查技巧一次性讲透包括Token续签、KID使用、算法选择、默认密钥风险这些最容易踩坑的地方。适合后端开发、安全工程师以及所有用JWT做登录态管理的同学。不管你是刚接触JWT还是已经被线上问题折磨过这篇都值得看。1. JWT到底是什么先把它拆开看1.1 一个JWT长什么样三个部分各管什么JWT的完整样子是三个由点号分隔的字符串分别叫做Header、Payload、Signature。Header里存放的是签名算法和Token类型Payload里放的是业务声明Signature是根据前两部分和密钥生成的签名。举个例子eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9 .eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6ImFkbWluIiwiYWRtaW4iOnRydWV9 .dozjgNryP4J3jVmNHl0w5N_XgL0n3I9PlFUP0HSsQ8这三段都是Base64URL编码注意不是普通Base64而是把和/换成了-和_同时去掉末尾的。很多解析异常就出在这里用错编码方式就会报错。第一段Header解码后长这样{ alg: HS256, typ: JWT }这一小段是攻击者最喜欢动脑筋的地方。alg字段声明了签名算法如果服务端不加验证直接信任就会引出著名的“algnone”绕过漏洞。第二段Payload是业务声明的集合格式类似{ sub: 1234567890, name: admin, admin: true }这里存放的是claimsub表示用户标识name是显示名admin是自定义权限标记。注意Payload只是Base64URL编码不是加密任何人都能解码看到内容所以敏感信息绝对不能直接放进去。第三段Signature由Header中的算法和密钥生成例如HMAC SHA256时就是HMACSHA256( base64urlEncode(Header) . base64urlEncode(Payload), secret )签名的作用是防止内容被篡改以及验证Token确实由服务端签发。它不能对内容保密这一点很多人搞混。很多JWT相关文档上来就讲三段结构但真正影响安全的是这三段在不同场景下如何被解析、被信任以及密钥如何管理。1.2 JWT为什么这么流行哪些场景适合用它JWT流行的核心原因在于无状态和跨域友好。服务端不保存会话拿到Token验签通过就算认证成功天然适合分布式系统和前后端分离。对比传统SessionSession需要服务端保存一份会话状态在多实例部署时要引入共享存储或者粘滞会话而JWT把状态搬到了客户端。但这并不意味着JWT可以无脑替代Session。JWT适合一次性Token、短期会话、跨系统单点登录、API鉴权这些场景如果业务需要服务端主动吊销用户比如封号、踢人下线JWT会非常别扭因为Token还在客户端手里你很难直接让它失效。真要解决只能靠黑白名单、版本号或者缩短过期时间。SPA项目里用JWT做登录态是现在的主流做法。前端在登录成功后拿到Token之后每个请求在Authorization头里带上Bearer token服务端解析校验。我们常说的“发包格式”就是这行Header别小看它很多调试问题都出在这里。至于验证码、短信验证码之类的流程通常和JWT是分层关系验证码校验通过之后才进入Token签发环节。2. JWT安全机制的整体设计与防线划分JWT安全不是把密钥改复杂就完事。我习惯把防线分成三层签名与密钥层、签发与校验层、传输与存储层。一层失守其他层还能兜底。如果只做某一层的加固比如只把密钥设得超长其他环节漏洞百出一样会被打穿。2.1 第一道防线签名算法与密钥管理算法选择上HS256是共享密钥对称签名速度快但要求参与校验的所有方持有同一个密钥。实际上密钥只能保存在服务端如果客户端拿到密钥就可以任意伪造Token。RS256和ES256是公私钥非对称签名公钥用于验签私钥用于签发适合开放平台或第三方接入场景。密钥管理的核心问题是“绝不能硬编码在代码里更不能使用默认密钥或弱密钥”。现实中很多项目出问题不是因为算法不行而是密钥泄露。业界通报过不止一次配置中心或网关组件因默认JWT密钥泄露导致攻击者直接伪造管理员Token绕过认证。这不是危言耸听我自己就处理过类似事故。密钥要放在环境变量、密钥管理服务或者配置中心而且要定期轮换。每个环境必须使用不同密钥测试环境和生产环境混用密钥是我见过最坑的操作之一。对于HS256密钥长度至少32字节且要足够随机别写jwt-secret-123456这种。对于RS256私钥要妥善保管泄露后果等同密钥泄露。2.2 第二道防线签发与校验的完整流程签发流程要回答三个问题Token给谁有效期多久里面放什么登录成功后服务端根据用户信息生成Token设置过期时间通常15分钟到2小时。不要把密码、身份证号、手机号等敏感数据写进Payload哪怕只是Base64编码也是明文。校验流程比签发更重要很多人栽在“只验签名不过期”或者“只验过期不验签名”。完整的校验至少包括签名是否有效是否过期校验exp是否为未来签发的Token校验iat接收方是否正确校验aud签发方是否可信校验iss用户是否存在、状态是否正常如果用的是非对称算法还要检查公钥的来源是否可信。另外kid字段不能盲目信任它只是告诉服务端用哪把密钥如果攻击者把它换成none或者路径字符串配合某些库的密钥解析逻辑就能产生注入风险这个后面专门讲。2.3 第三道防线传输与存储层的注意事项Token就是你的身份凭证泄露Token等于把账号交给别人。传输层必须用HTTPS明文HTTP下抓个包就能拿走Token。存储层要看场景。SPA项目里localStorage容易被XSS脚本窃取Cookie方式如果不加HttpOnly同样能被脚本读取。方案选择上常见做法是短期Token放内存或localStorage配合刷新机制更稳妥的是把刷新令牌放HttpOnly Cookie访问令牌放内存页面刷新后通过静默刷新获取新Token加HttpOnly、SameSite属性降低XSS和CSRF风险XSS和CSRF是两种不同的威胁。XSS靠脚本偷TokenCSRF靠浏览器自动携带Cookie发起请求。如果你把Token放在Authorization头里CSRF的风险会小很多但XSS的风险依然存在所以前端代码也必须做输入输出转义。3. 核心细节拆解从Header到Signature的坑这一章写的是最容易出问题、也最容易被忽略的细节。很多安全漏洞藏在“理所当然”的写法里等你真正踩到往往已经造成了线上事故。3.1 Header里的alg、typ、kid到底怎么用alg字段决定签名算法。攻击者最经典的操作是改成none如果服务端代码读算法时没有白名单校验并且库支持none算法Token就完全不用签名。修复方式很简单在验签前先确认算法在允许列表里并且解析时强制指定算法而不是从Token里读。typ字段一般表示JWT类型很多库不强制要求缺省也能解析。这个字段的安全影响不大但如果你做严格的格式校验可以要求必须是JWT。真正的麻烦在kid字段。kid用于从多个密钥中选一个。问题在于很多实现会拿kid去拼接密钥路径比如kid key-1对应/keys/key-1.pem。如果攻击者把kid改成../../etc/passwd某些库会按路径读取文件内容充当密钥造成任意文件读取甚至密钥混淆。修复思路有两个第一kid必须是服务端定义好的字符串不要直接拼接路径用映射表或数据库查询第二对kid做严格的白名单校验。3.2 Payload声明的正确打开方式Payload里的claim不是随便写。标准声明里exp是过期时间iat是签发时间nbf指在这之前不可用jti是唯一Token IDaud是目标受众iss是签发者。每一个字段背后都有明确的安全目的。过期校验经常出问题的点是时钟偏移。多台服务器之间时间不同步可能导致Token早几分钟过期或晚几分钟生效。很多库提供了setClockSkewSeconds之类的参数建议设置成一个很小的值比如5秒而不是默认的30秒甚至0秒。过大的偏移会变相延长Token有效期过小又容易在分布式环境下误杀。nbf字段有时候被忽略如果Token里面带了nbf校验时也要检查当前时间是否在nbf之后不然可能出现“Token还没生效就能用”或相反的情况。jti则适合做一次性Token、防重放和Token吊销后面实操部分会讲到。3.3 Signature签名与验签的底层逻辑签名算法的底层逻辑是单向性。HMAC用同一个密钥计算和验证RSA和ECDSA用私钥签、公钥验。验签时如果库允许你同时支持对称和非对称算法就可能出现算法混淆攻击攻击者把RS256改成HS256然后用公钥当作HMAC密钥去签名。因为公钥通常是公开的相当于攻击者拥有了“签名密钥”。应对方法就是前面说的验签前固定算法白名单不要让算法类型随Token变化。如果你用JWT库自带的parser要把签名算法限定为单一类型或明确列表。这个细节我说了很多次因为它几乎是JWT漏洞里最容易被忽略的一类。还有一个底层认知签名能防篡改但防不了重放。攻击者可以不修改Token而是把这个Token重新发一遍。如果Token过期时间设得很长又没做一次性Token的机制重放攻击就有了可乘之机。像支付、改密这种敏感操作应该用jti做一次性校验或者要求额外验证。4. 实操从登录到Token续签的完整实现前面把原理和安全注意点讲完下面给一套能照着做的完整实现。我会以Java和Spring Boot作为示例但思路对所有语言都通用。4.1 登录签发Token的标准姿势登录接口先校验验证码、用户名密码校验通过后再生成Token。SPA项目开发中经常遇到的“JWT验证码实现”其实就是把图片验证码或短信验证码的校验放在签发Token之前。验证码环节独立于JWT校验成功后才走签发逻辑。伪代码示例public LoginResponse login(LoginRequest request) { // 1.校验验证码 if (!captchaService.verify(request.getCaptchaId(), request.getCaptchaCode())) { throw new BusinessException(验证码错误); } // 2.校验用户名密码 User user userService.authenticate(request.getUsername(), request.getPassword()); // 3.签发JWT String token Jwts.builder() .setSubject(user.getId().toString()) .claim(username, user.getUsername()) .claim(role, user.getRole()) .setIssuer(auth-service) .setAudience(business-api) .setIssuedAt(new Date()) .setExpiration(new Date(System.currentTimeMillis() 30 * 60 * 1000)) .signWith(secretKey) .compact(); return new LoginResponse(token, user.getUsername()); }注意几个细节subject不要放用户名而放用户ID。用户名可能重复ID才是唯一标识。role可以放但权限校验不能只看Token里的role还要结合后端实时查询防止权限变更不及时。过期时间先设30分钟后面在续签设计里再调整。4.2 请求拦截与解析校验SPA项目里的每个API请求都会在Authorization头带上Bearer token后端用过滤器或拦截器统一解析。核心逻辑是String header request.getHeader(Authorization); if (header ! null header.startsWith(Bearer )) { String token header.substring(7); try { JwsClaims jws Jwts.parserBuilder() .setSigningKey(publicKey) .setClockSkewSeconds(5) .build() .parseClaimsJws(token); Claims claims jws.getBody(); // 在这里校验iss、aud、用户状态等 request.setAttribute(userId, claims.getSubject()); } catch (JwtException e) { // 记录日志返回401 } }注意是substring(7)因为Bearer包含一个空格少拿一个字符或者多拿一个都会导致解析失败。这个“JWT发包格式”的问题很常见很多人拿不到Token后自己找半天其实只是取错了下标。过滤器中还要区分白名单路径比如登录、注册、验证码获取这些接口不需要Token要放行其余接口都需要校验。同时校验逻辑不要放在业务代码里重复写统一放过滤器或者写个注解配合AOP。这样排查问题时只有一个入口不至于每个接口都各自为政。4.3 Token续签的三种方案与取舍Token总会过期过期后用户要重新登录就很烦。常见续签方案有三种第一种是固定刷新接口。用户拿着“刷新令牌”去调/refresh接口服务端校验刷新令牌合法后签发新的访问令牌。刷新令牌有效期长通常放在HttpOnly Cookie里。缺点是刷新令牌本身也是凭证需要额外的存储和吊销机制实现复杂度高。第二种是临时续签。在访问令牌快要过期时客户端主动调一个续签接口服务端校验旧Token没过期太长时间直接签发新Token。实践上可以约定在过期前5分钟续签但要注意反射攻击和重放风险。第三种是滑动过期也叫sliding expiration。用户每次访问时只要Token剩余有效期超过某个阈值就自动签发新Token把过期时间往后推。Redis的EXPIRE更新就是这个思路。缺点是长活跃用户永远不过期安全上要配合空闲超时限制。如果只做普通后台管理系统我建议用“短期访问令牌固定刷新令牌”访问Token设15分钟到2小时刷新Token设7天到30天。这样既减少暴露面用户体验也说得过去。下表简单对比方案复杂度用户体验安全性适用场景固定刷新接口中好高前后端分离、移动端临时续签低中中内部系统、短期项目滑动过期低最好低对安全要求不高的系统滑动过期看着省事但却是最容易出安全事故的方案之一因为Token永远不过期意味着一个被偷的Token可以无限期使用。如果要用必须叠加服务端心跳机制长期不活跃的会话强制下线。4.4 与Spring Security整合时的最佳实践Spring Security整合JWT是Java后端的高频场景。很多人先看一堆教程然后遇到“过滤器不生效”“权限不匹配”“白名单失效”这类问题最后卡一整天。核心配置就几点第一关闭Session使用无状态模式第二把JWT过滤器注册到UsernamePasswordAuthenticationFilter之前第三在SecurityConfig中定义白名单第四把过滤器注入到容器。http.csrf().disable() .sessionManagement().sessionCreationPolicy(SessionCreationPolicy.STATELESS) .and() .authorizeRequests() .antMatchers(/auth/login, /captcha, /auth/refresh).permitAll() .anyRequest().authenticated() .and() .addFilterBefore(jwtAuthFilter, UsernamePasswordAuthenticationFilter.class);整合中最常踩的坑是过滤器里没有把用户信息放进SecurityContext导致后续PreAuthorize拿不到当前用户。正确做法是在过滤器里用UsernamePasswordAuthenticationToken包装用户信息和权限调用SecurityContextHolder.getContext().setAuthentication()然后设置请求属性供业务代码使用。同时过滤器里出现的异常要在AuthenticationEntryPoint统一处理否则会返回默认错误页而不是JSON结构。还有一个冷门坑如果项目里有多个Dispatcher或者使用WebFlux过滤器配置方式完全不同千万不要把Servlet的写法直接搬过去。5. 常见JWT漏洞与安全加固实录这一章是干货中的干货。我整理了几个JWT项目里最常见的漏洞以及对应的加固方法最后放一个真实事故复盘。5.1 攻击者最常利用的漏洞清单JWT漏洞我归纳成六类算法混淆、弱密钥爆破、密钥泄露或默认密钥、KID注入、过期校验缺失、Token日志泄露。算法混淆就是前面说的RS256转HS256攻击者用公钥当HMAC密钥脱离算法白名单就能绕过验签。弱密钥爆破则是用字典暴力匹配比如密钥是secret、password这种攻击者拿一个JWT就能爆破出签名密钥。密钥泄露或使用默认密钥更直接攻击者自己签发Token。KID注入则是利用kid参数读取任意文件或混淆密钥。过期校验缺失表现为只验Token格式不验exp或者开发阶段关掉了过期校验上线忘记打开。Token日志泄露则是将Authorization头或完整JWT打入日志一旦日志被读所有会话都在攻击者手里。漏洞类型成因危害自查方法算法混淆解析器允许算法切换绕过验签伪造任意用户固定算法白名单弱密钥爆破密钥长度短、可猜测Token可被伪造检查密钥长度和随机性密钥泄露/默认密钥硬编码、配置错误任意签发Token扫描代码和配置文件KID注入kid未校验且拼接路径任意文件读取、密钥混淆白名单映射kid过期校验缺失未校验exp或故意关闭过期Token持续有效核对校验链路Token日志泄露打印完整JWT会话被窃取日志脱敏把这六类漏洞记在心里写代码时对照一下能规避绝大多数JWT安全问题。5.2 一份可直接照抄的安全加固清单加固不是一次性动作而是持续习惯。下面是我在项目中实际执行过的清单你可以直接拿去做代码Review算法固定为RS256或HS256解析器强制指定算法不接受algnoneHS256密钥长度至少32字节RS256私钥2048位以上密钥存环境变量或密钥管理服务每个环境独立密钥禁止明文写进配置文件校验exp、iat、nbf、iss、aud启用时钟偏移容错使用jti唯一Token ID配合Redis存储已吊销或已使用的Tokenkid使用白名单映射禁止直接拼接路径Token放Authorization头不用Cookie或只用HttpOnly SameSite Cookie日志脱敏不记录完整Token敏感操作强制二次验证监控异常签发和频繁校验失败的行为每一条看起来都很基础但很多项目往往败在细节。我见过生产环境密钥写在application.yml里然后提交到Git仓库的也见过测试环境的弱密钥被带到生产环境的。这些不是能力问题是流程问题。所以加固清单不能只停留在“知道”要落到CI扫描、代码审查和发布流程里。5.3 踩坑记录一次线上Token绕过事故复盘前几年我接手过一个老系统登录态用的是JWT排查问题时发现攻击者能伪造任意用户。第一次看到日志时我有两个直觉一是算法被改成none二是密钥泄露。查了一圈签名算法没问题问题出在签名密钥是一个写在代码里的固定字符串而这个代码在三年前的Git历史里就存在。这个字符串长度只有12个字符有大小写和数字但高度可猜测。攻击者通过工具拿到了密钥然后签发了带有管理员权限的Token直接绕过登录调内部接口。复盘后我们做了几件事第一把密钥从代码里移除改为环境变量并轮换密钥第二引入版本化的密钥管理通过kid区分当前密钥和旧密钥实现平滑轮换第三在代码审核阶段增加密钥扫描工具防止敏感信息再次入库第四增加安全日志对异常签发的Token做告警。这次事故让我意识到JWT安全机制是个系统性问题单靠某个库的安全特性救不了命。6. 常见问题排查与经验技巧最后这部分是实用问答和一些冷知识。你可以把它当成速查表遇到问题直接查。6.1 高频故障排查速查表症状可能原因排查方法客户端刚登录请求却返回401服务器时钟偏移过期校验误判检查服务器NTP同步设置小的时钟偏移容错Token解析抛SignatureException密钥不匹配或密钥被轮换对比当前密钥与签发密钥检查kid解析报base64url字符非法用了普通Base64解析JWT改用Base64URL解码去掉补位字符请求头有Token但取不到值Bearer后没加空格或取的下标错误检查Authorization头格式确认Bearer后有空格某段时间后突然全部401多实例部署密钥不一致检查每个环境的密钥配置Token解出来看到敏感信息Payload直接放明文数据立即修改敏感字段只放引用或分离存储排查麻烦时最直接的手段是写一个小脚本模拟签名和验签过程确认密钥与算法是否匹配。只要能在本地复现线上问题基本就能解决。6.2 经验技巧与冷知识JWT的官方文档通常很简洁真正有用的经验要在项目里积累。分享几个心得。jti字段不要只是随机字符串你可以把它和用户ID、设备信息绑定做成可追踪的凭证标识。当用户修改密码或者被踢下线时根据jti吊销对应Token。JWT不提供主动吊销能力。需要吊销时要么维护一个黑名单要么用版本号。我建议在用户表里加一个token_version字段签发Token时把这个版本号和jti一起写进Payload请求校验时对比版本号不一致就拒绝。这样不管是改密码还是踢人只要把版本号加一旧Token全部失效。冷知识JWT的Payload可以放进Cookie但要注意长度限制。Token太长不要放在URL参数里否则日志系统很可能把它记录下来从而泄露。调试JWT有一句话先用jwt.io解一下再用代码解一下。很多“库不认识Token”的问题其实是因为Token被URL解码或空白字符污染了。6.3 关于JWT安全我最后想说的在我处理过的所有JWT相关事故里没有一起是纯粹的算法漏洞基本都是密钥管理、校验缺失和日志泄露这些低级问题。所以我的建议很朴素把密钥当数据库密码一样保护把校验逻辑写成强制规范剩下的交给时间和监控。这个方法我用下来很稳你也可以试试。JWT不是银弹但它足够好用。坚持做好密钥管理、算法白名单、完整校验、传输加密和合理续签这套机制完全能支撑生产环境。如果架构允许别把JWT当成唯一鉴权层配合网关、权限中心做二次校验安全性会高很多。如果你现在正在排查一个诡异的401或者正在给项目加认证机制希望这篇文章能帮你少走弯路。
返回列表