做过后端开发的朋友大概率都遇到过这种场景:项目马上要提测了,安全测试那边甩过来一份报告,标题写着“JWT鉴权链路存在高危风险”,下面跟着弱密钥、算法混淆、token可伪造几个大字,然后你的周末就没了。JWT本身只是一个格式规范,它不是框架,也不替你做安全决策,用得好是登录态管理利器,用得糙就是给攻击者留后门。
这篇内容是我这几年在Spring Boot、.NET Core和一些SPA项目里和JWT交手的经验总结,会从攻击原理一路聊到框架落地,把jwt破解、jwt伪造的常见路径、token续签怎么实现、Swagger要不要放行这类反复踩坑的点都过一遍。不管你是刚接触JWT还是已经被线上事故磨了一轮,应该都能从中找到对应的解法。
1. 先看懂攻击面:JWT为什么会被破解和伪造
1.1 从“三段式”结构看攻击面
JWT整体上是一串用点号分隔的字符串,比如:
code复制eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4ifQ.SflKxwRJSMeKKF2QT4fwpMeJf36POk6yJV_adQssw5c
从左到右分别是Header、Payload、Signature。Header里保存token类型和签名算法,Payload里放用户相关声明,Signature是用密钥对前两段内容做的签名。
关键点在于:前两段只是Base64Url编码,不是加密。任何一个jwt在线解析工具都能直接读取里面的内容,甚至直接帮你把第三段签名去掉改成“无签名”版本。很多人第一次用JWT时,看到payload里可以塞任意JSON,就顺手把用户手机号、邮箱、角色、部门全放进去了,这就是典型的“把签名当成加密”的误用。
签名只保证token的完整性,不保证内容的机密性。服务端验签成功只说明“这段内容确实由持有密钥的一方生成,且没有被改过”,但任何能拿到token的人都能看见payload里的所有字段。理解这一点是后面所有安全加固的基础。
1.2 攻击者最常用的四条攻击路径
攻击者拿到一个JWT后,通常会按下面几条路径去试。
第一是alg none攻击。把Header里的alg改成none,然后把签名部分清空,直接提交给服务端。理论上服务端验签时必须拒绝这种请求,但很多早期JWT库在实现时,看到alg是none就直接跳过签名校验。只要业务代码里没有显式拒绝none,等于攻击者可以随意构造身份登录。我自己在测试一些老项目时,用这个方法成功率并不低,说明它不是书本上的冷门漏洞。
第二是算法混淆攻击。这是最恶心的一种。服务端如果用RS256这类非对称算法验签,公钥本身是公开的,可以被任何人拿到。攻击者把Header里的alg改成HS256,如果服务端没有校验“当前token声明使用的算法”和“服务端配置的算法”是否一致,就可能直接用公钥作为HMAC对称密钥来验证签名。由于公钥是公开的,攻击者也能拿公钥当密钥签发一个新token,服务端验签照样通过。
第三是弱密钥爆破。HS256的签名和验签用的是同一个对称密钥,密钥一旦短小、可预测,攻击者本地就可以跑常见密钥词表,用hashcat这类工具瞬间爆破出真正的secret。很多网上的教程代码直接写死一个“mySecret”,项目上线也忘记改,等于把门锁钥匙挂在门上。
第四是水平越权与信息泄露。这个问题其实和JWT被伪造无关,但同样由JWT使用不当引发。当payload里放了userId,而接口只校验了“登录态有效”,没有校验“这个userId是不是当前登录用户的userId”,攻击者只需要改动payload里的userId,再拿着自己的token去访问别人的数据。有些团队连签名都不换,只改payload就能越权,这种情况我见过不止一次。
所以你看,真正出问题的往往不是JWT算法本身,而是实现方的三个失误:过度信任请求里带来的alg、使用弱密钥、在payload里塞了不该塞的数据。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 开发阶段堵漏洞:密钥、过期与payload内容管理
2.1 密钥的生成、存储与轮换,别把代码示例的secret带进生产
先说结论:HS256场景下,密钥必须是足够长的随机字符串,建议256位以上,用下面命令生成:
bash复制openssl rand -base64 64
生成出来是类似这种:
code复制9QaPTu2RjB3CzN7Lk1W0XmEfYgHvBs6dU4oRj8KqM0=
放在配置中心、环境变量或密钥管理服务里,绝对不允许硬编码在代码中、提交到Git仓库。有一个很常见的坑是:有些教程里为了演示方便写了一个固定的secret,学习者直接复制到项目里,结果上线后被扫描器直接命中。攻击者在爆破JWT密钥时,会先跑一批教程默认值、常见弱密钥、年份组合等,所以密钥必须是自己随机生成的。
再说密钥轮换。很多团队根本没有密钥轮换机制,一旦密钥泄露就要所有用户退登,代价很大。一个实用方案是给JWT增加kid字段,用kid标记当前token由哪个密钥签发,服务端维护密钥列表,释放新密钥后保留旧密钥的宽限期。宽限期内新旧密钥都允许验签,但签发统一用新密钥。到期后把旧密钥从配置中移除,所有使用旧密钥签发的token自然失效。
2.2 token过期控制与续签:无状态带来的主动失效难题
JWT最大卖点是“无状态”,服务端不需要保存会话,因此非常适合水平扩展。但无状态也带来一个问题:token一旦签发,在过期之前服务端很难主动让它失效。如果exp设置得很大甚至不设置,等于给攻击者留了一扇长期有效的门。
我建议token过期时间根据业务场景来定:
- 普通后台管理系统:access token 30分钟到2小时比较合适
- 移动端API:可以考虑7天到30天,但要配刷新机制
- 高安全场景:15分钟以内,配合refresh token
有些人会在payload里塞一个很大的exp,比如“记住我30天”,这在低风险应用里也许能接受,但对于涉及资金、个人信息、管理后台的业务,非常不建议。你在日志里不小心泄露一个30天有效的token,和被拖库没太大区别。
如果确实需要长时间维持登录态,正确做法是引入refresh token机制。大致思路是:access token短期有效,refresh token长期有效,access token过期后,客户端用refresh token换新的access token。refresh token需要存服务端,可以是数据库里的随机串,也可以是JWT但必须支持吊销。
对于SPA项目,很多人直接用滑动续期思路:每次请求校验token时,如果距离过期时间不足一定阈值,就重新签发一个token返回给前端。这个方案实现简单,但要注意每次请求都会刷新token,导致日志里出现大量token,泄露风险会放大。个人经验是滑动续期配合固定过期上限,比如token过期时间为30分钟,但只要用户持续操作就可以续签,而距离上次签发超过12小时就强制重新登录。
另外,即便使用了refresh token,也要在Redis里维护一个黑名单或token版本号。否则用户“退出登录”后,refresh token依然有效,会话根本关不掉。
2.3 payload里该放什么不该放什么
我在1.1里已经说了,JWT的payload是明文,谁都能读。所以判断标准很简单:不能被用户看到的字段,一律不放。
适合放在payload里的:
- userId、用户唯一标识
- 角色编码或权限标识(仅用于减少一次查询,不代表可以完全替代权限校验)
- token唯一标识jti
- 签发时间iat、过期时间exp
不适合放在payload里的:
- 手机号、邮箱、身份证号
- 家庭住址、设备序列号等敏感信息
- 完整用户对象JSON
- 任何可能被日志系统完整打印的数据
有一个实际教训是:某项目为了减少查库次数,把用户头像和昵称都放进了payload,后来运维的同学为了方便排查问题,在nginx层把Authorization头打到了access.log里,结果日志平台被拖库时,几万条真实用户信息直接泄露。这种设计层面的问题,靠再强的加密算法也救不回来。
3. 主流框架下的JWT加固落地
3.1 Spring Boot场景:放开Swagger但不裸奔
“springboot jwt 放开swagger”这个话题在社区里被问烂了,因为很多项目需要让开发同学通过Swagger调试接口,但JWT过滤器一拦,Swagger页面根本打不开。常见的解决方案是把Swagger路径加入白名单,但问题往往出在白名单配置得太宽。
我见过一个线上事故:团队把/swagger-ui/**、/v3/api-docs/**加入permitAll后,又图省事把/api/**也放进去了,结果登录接口外所有接口都变成了匿名访问。更隐蔽的版本是,有人把/swagger**作为一个模糊前缀放行,结果某个业务接口恰好叫/swaggerOrder/list,也被白名单命中,直接裸奔。
正确做法是最小化精确放行,并且JWT过滤器仍然执行,只是在过滤器中判断当前路径是否在白名单内,如果在白名单内就直接放行,不解析token。以Spring Security为例:
java复制http.authorizeHttpRequests(auth -> auth
.requestMatchers(
"/api/auth/login",
"/api/auth/captcha",
"/v3/api-docs/**",
"/swagger-ui.html",
"/swagger-ui/**"
).permitAll()
.anyRequest().authenticated()
);
代码里只放行确切的登录接口、验证码接口和Swagger资源路径,坚持“最小可访问”原则。另外,Swagger在生产环境必须关闭,最好只在开发环境profile中加载。Swagger页面相当于给攻击者画了一张系统接口地图,即使它有鉴权,也大大降低了攻击者发现接口的成本。
再结合“spa项目开发之jwt验证码实现”这个常见需求说一句:登录接口建议加图形验证码或滑块验证码,并配套接口限流,否则攻击者可以拿着字典对用户名密码做爆破。验证码校验必须在后端生成、后端校验,不能只在前端校验。
3.2 .NET Core场景:Validate配置一点都不能省
.NET Core下JWT主要用Microsoft.AspNetCore.Authentication.JwtBearer,官方示例看起来很简单,但有几个配置项特别容易被漏掉。先看一个基本的完整配置:
csharp复制services.AddAuthentication(JwtBearerDefaults.AuthenticationScheme)
.AddJwtBearer(options =>
{
options.TokenValidationParameters = new TokenValidationParameters
{
ValidateIssuerSigningKey = true,
IssuerSigningKey = new SymmetricSecurityKey(
Encoding.UTF8.GetBytes(configuration["Jwt:Secret"])),
ValidateIssuer = true,
ValidIssuer = configuration["Jwt:Issuer"],
ValidateAudience = true,
ValidAudience = configuration["Jwt:Audience"],
ValidateLifetime = true,
ClockSkew = TimeSpan.FromSeconds(30)
};
});
关键在于:ValidateIssuer、ValidateAudience、ValidateLifetime这些默认并不是全部开启的。有人只配置了IssuerSigningKey,其他验证项保持默认值,结果攻击者只要用已知密钥签一个token,或者篡改payload里的过期时间,服务端也会直接信任。就算密钥没泄露,如果不校验issuer和audience,token被用于其它系统也是一种典型的风险。
ClockSkew默认有5分钟宽限期,这是为了容忍服务端和客户端之间的时钟偏差。但如果你对token过期时间要求很严格,5分钟显然太长,一般设成30秒到1分钟即可。反过来,如果服务端时钟本身没做同步,设得太短会导致正常用户提前掉线,所以要先确保服务器使用了NTP时间同步。
还需要特别提醒的是,如果项目里使用app.UseHttpsRedirection(),注意token不要在HTTP明文环境下传输。JWT和Cookie一样,一旦被中间人截获,和直接泄露账号密码差不多,生产环境务必全站HTTPS。
3.3 用Redis兜底实现可注销的JWT会话
JWT无状态听上去很美,但做后台管理系统或需要“踢人下线”的功能时,你会发现无状态其实是一种麻烦。后台管理员封禁某个用户后,该用户手里的JWT在过期前依然有效,如果token过期时间又设置得比较久,封禁就形同虚设。
我的做法是给JWT加一层Redis状态控制,兼顾无状态的扩容优势和可控的会话管理。整体流程分四步:
- 登录成功后签发JWT时,在payload里加一个
jti字段,作为token的唯一ID,同时把jti写入Redis,value可以是userId,过期时间与token的exp保持一致。 - 在JWT认证过滤器中,验签通过后拿着jti去Redis查一次,如果不存在说明token已被注销或已经超时,直接返回401。
- 用户主动退出时,除了清空前端token,后端把jti删掉即可。
- 需要封禁用户或踢人下线时,把该用户所有有效jti加入黑名单,或直接删除对应Redis键。
这个方案相比纯无状态只多了一次Redis查询,但换来的是“可控的会话生命周期”。你也可以把Redis改成内存缓存,但在多实例部署时内存缓存会出现不一致,推荐直接使用Redis,反正现代项目基本上都已经引入了Redis。
对于访问量很大的纯API场景,如果不想每次请求都查Redis,可以折中:只把短时间的access token做成真正的无状态,refresh token存服务端。比如access token 15分钟过期,期间即使不查Redis影响也可控;超过15分钟后必须用refresh token换新,而refresh token可以被吊销,这样只要保护好refresh token和吊销机制,整体安全性就是一个可接受的水平。
4. 做安全测试和排查时,我总结的避坑清单
4.1 高频隐患速查表
| 风险点 | 攻击方式 | 危害 | 修复建议 |
|---|---|---|---|
| alg=none | 修改Header去掉签名 | 直接伪造任意身份 | 指定算法白名单,显式拒绝none |
| 算法混淆 | HS256/RS256混用 | 用公开公钥伪造token | 验签时固定算法,不接受Header动态切换 |
| 弱密钥 | 常见词表爆破 | token被破解和伪造 | 用openssl rand生成256位以上随机密钥 |
| payload敏感信息 | jwt在线解析或流量截获 | 个人信息批量泄露 | payload只放非敏感标识,业务数据走接口查询 |
| 过期时间缺失/过长 | 重放或永久盗用 | 会话长期失控 | 设置exp和合理有效期,配合refresh token |
| 注销失效 | 旧token继续访问 | 封禁和退出形同虚设 | Redis维护黑名单或jti白名单 |
| 密钥硬编码 | 扫描器直接命中 | 全系统用户token可伪造 | 密钥放配置中心或环境变量 |
| Swagger白名单过宽 | 未授权访问接口 | 业务功能被匿名调用 | 精确到具体路径,生产环境关闭 |
这张表基本覆盖了我在代码评审中反复提到的点,如果你负责的团队有JWT相关代码,建议直接拿这个表去做一次自查。
4.2 一次jwt伪造的完整排查过程
有一次我接手一个老项目,安全测试给的漏洞标题就是“JWT伪造:任意用户登录”。排查过程让我印象很深,因为问题并不是单一环节导致的,而是三层问题叠加。
第一层是登录后的payload把role字段和nickname直接写进去了,攻击者连token都不需要破解就能看到当前用户的角色名。第二层是服务端用的secret竟然就是网上某篇教程里的key,安全测试用hashcat几秒钟就爆出来。第三层是服务端的验证代码用的是老版本库,对alg字段没有做任何白名单限制,攻击者把Header从RS256改成HS256,再用公开的公钥重新签名,服务端就放行了。
修复过程分三步:首先给payload瘦身,只留userId和jti;其次重新生成密钥并放到配置中心;最后升级JWT库,在验签时显式指定只允许RS256或HS256中的一种,并把alg=none直接加入拦截。核心逻辑大概是:
java复制Jwts.parserBuilder()
.requireIssuer("your-issuer")
.setSigningKey(publicKey)
.setAllowedClockSkewSeconds(30)
.build()
.parseClaimsJws(token);
新版本库基本不再允许通过setSigningKey传入字符串来同时处理多种算法,而是要求明确使用公钥或私钥,这类设计本身就在逼你收敛算法选择。
那次排查给出的教训是:遇到JWT类安全漏洞,不要只盯着一处代码改,要从密钥、算法、payload、过期策略、日志泄露五个维度一起看,否则漏洞会换个形式重新出现。
4.3 容易忽略的边界场景与调试技巧
有几个场景是文档里不太会写,但实战里经常踩的。
时钟偏移问题。JWT的exp、iat、nbf都依赖系统时间。如果服务端是多实例部署且没有做NTP时间同步,或者数据库服务器和应用服务器之间有较大时钟差,用户会出现“刚登录就提示过期”或“过期了还能访问”两种极端情况。建议应用服务器统一加NTP同步,服务端校验时留几秒的时钟偏移量。
token打日志问题。调试时为了快速定位问题,很多人会把Authorization头完整打印在日志里。一旦日志平台被渗透,等于全量会话泄露。如果一定要打日志,至少要做脱敏,比如只显示前10个字符和后5个字符,中间用星号替代。
Token存储位置问题。SPA项目常见做法是把token放在localStorage或sessionStorage里,好处是js方便读取,坏处是一旦出现XSS漏洞,攻击者可以直接把token拖走。如果前端XSS防护做得一般,可以考虑把token放到HttpOnly Cookie里,同时做好CSRF防护,因为Cookie模式天然容易引入CSRF。两者没有绝对标准答案,但你需要知道每种选择的代价。
另外还有一个容易被忽略的问题:expires字段在服务端解析出来是DateTime或long,不同语言的类型转换可能会有精度差异,尤其要注意秒级和毫秒级的单位统一。我曾经因为.NET Core里把过期时间配置成了毫秒级,而Java服务端期望的是秒级,导致两边验签互相不认。这种问题排查起来非常隐蔽,因为表面上配置看着都对。
最后再分享一个小技巧。做安全自查时,你可以在测试环境用已知密钥生成一个过期时间为一小时后的token,然后修改payload里的userId,再重新签名,用这个token打你所有的业务接口。如果任何一个接口只是校验了“token是否有效”,而没有校验“当前用户是否就是token里的这个用户”,那水平越权的漏洞就已经存在了。很多JWT安全问题在开发阶段用这一个动作就能发现大半。
我的总体感受是,JWT不是洪水猛兽,也不是银弹。它能否安全落地,取决于实现方对密钥、算法、生命周期、信息暴露边界的管理是否足够敬畏。用过一段时间后我还是会更倾向于短时token加服务端状态控制的组合,虽然多了一点存储开销,但换来的是可注销、可审计、可管控,这对很多业务场景来说远比“无状态”这个卖点更重要。
