这两年做安全测试,JWT(JSON Web Token)在项目里出镜率实在太高了。从大厂的中台服务到创业公司的小后台,几乎人人都拿它做登录态管理、接口鉴权、单点登录,但说实话,我用过的、看过的这些项目里,真正把JWT用明白的不多。我见过把Token直接塞进URL里倒腾的,见过移除签名还能通过校验的,也见过密钥就是secret的——字典里躺着的rockyou一梭子打穿。JWT本身是个非常轻量的认证方案,它的安全边界全靠使用者自己把握,而这个"把握"恰恰是最容易翻车的地方。这篇文章就把我在实际渗透测试、代码审计和服务端方案评审里碰到的JWT安全漏洞和常见攻击方式捋一遍,再给出一套能直接落到团队里的防御清单。
和JWT打交道比较多的人,无论是后端开发、安全测试还是运维同学,这篇都值得花几分钟过一遍。原理部分我尽量讲得直白,案例都是真实场景抽象出来的,不涉及具体公司,但手法和防御思路完全可以复用。
1. 先把JWT拆开了看:Token和JWT到底什么关系
很多人在最开始就栽在一个小概念上:Token和JWT是不是一回事?答案是有交集但不是同一个东西。Token是个泛指,只要是服务端签发给客户端、用来证明身份的凭证都可以叫Token;JWT是Token的一种具体实现标准。你可以把JWT理解为一种"自带信息的通行证",服务端把用户身份、过期时间、权限范围直接编码进这张证里,后面每次校验只需要验签,不需要回头查数据库。
1.1 JWT三大段:Header、Payload、Signature
JWT长这样:
eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMDAxIiwibmFtZSI6ImFkbWluIiwicm9sZSI6InVzZXIiLCJleHAiOjE3MDAwMDAwMDB9.9XZ3uFmYh1VY6j3gHtLv0nM3k7XxPqG1RkYvN7sWv6A这串东西用点号分成三段,分别是Header、Payload、Signature,每一段都是Base64Url编码后的JSON内容,再加一起用指定算法签名。
Header部分记录了两件事:签名算法(alg字段)和Token类型(typ字段),典型值是{"alg":"HS256","typ":"JWT"}。服务端看到这个Header才知道该拿什么算法去验证签名,而这个"客户端可控的Header"恰恰埋下了后面一大堆漏洞的引子。
Payload是业务数据区,可以自定义放user_id、role、exp(过期时间)、iat(签发时间)这类字段。注意它只是Base64Url编码,不是加密,任何人拿到Token都能用解码工具直接看到里面的明文内容。很多人把手机号、邮箱甚至密码哈希往里塞,等于把敏感信息直接暴露在攻击者面前。
Signature是用Header和Payload拼起来之后,拿密钥做HMAC或RSA签名得到的一段指纹。服务端校验Token时重新算一遍签名,对得上才认为Token可信。
1.2 为什么JWT的安全问题如此普遍
我觉得问题出在三个地方。
第一,JWT是无状态的,服务端拿到Token只验签名、不查数据库。这带来性能优势,但也意味着Token一旦签发,在没有过期或撤销机制的情况下,服务端对它的控制力几乎为零。你没法像Session那样手动把它拉黑。
第二,JWT的Header和Payload都是客户端可见的,攻击者思路天然比攻击Session要开阔——他不用蒙,直接能看到算法、能看到业务字段,攻击路径一目了然。
第三,也是最核心的一点:很多开发者对JWT的理解停留在"会用库"的层面。调用一个verify()函数以为就安全了,完全没意识到这个函数验证的算法是攻击者可控的输入,也没意识到哈希签名和公钥验签这俩场景根本是两套安全模型。我后面讲算法混淆攻击,就是踩在这种理解偏差上的。
2. 最常见的几种JWT漏洞与攻击手法拆解
这一节是全文的核心,我按攻击手法的类型逐个拆。每一种都先说清楚原理,再讲攻击套路,最后说防御方向。
2.1 算法混淆攻击:从alg:none到RS256降级HS256
算法混淆是JWT最有代表性的漏洞类型,攻击核心是"操纵Header里的alg字段,让服务端用攻击者希望的方式去验签"。
先说最简单的alg:none。这是一种古老的攻击方式,把Header改成{"alg":"none"},签名那段直接留空,很多较老版本的JWT库在收到alg为none的Token时会跳过签名验证,直接把Payload当作可信数据。我几年前测一个内部管理系统时就碰到过,把payload里的is_admin改成true,去掉签名,一个普通用户就变成了管理员。现在主流库基本都堵住了这个洞,但如果你用的是自研校验逻辑,或者代码里依赖的是非常老的版本,仍然有中招的可能。而且老系统迭代慢,这种洞常常在角落里躺个好几年。
再说更常见、也更难防的RS256降级HS256攻击。场景是这样的:服务端签发Token时用的是RS256(RSA非对称签名,私钥签名、公钥验签),但验证函数同时支持HS256(HMAC对称签名,用同一个密钥签名和验签),或者没有显式指定允许的算法列表。攻击者把Header里的算法改成HS256,把Payload改成伪造的数据,然后用什么密钥给这段数据算HMAC签名呢?用的是服务端的RSA公钥。
这个攻击的精妙之处在于:RSA公钥在客户端是可以拿到的,很多服务端还会把公钥放在/.well-known/jwks.json这类公开端点供第三方使用。当服务端用HS256去验证时,它直接用"密钥"去计算HMAC和Token里的签名进行比对,而这个"密钥"在攻击者手里就是公钥字符串。结果就是,攻击者用公钥当HMAC密钥签出来的Token,服务端验签通过。
我今年在某个项目做安全评审时又见到一次这个场景,虽然是个老问题,但实实在在还没死透。防御的核心很简单:校验alg时做白名单控制,只允许RSA或ECDSA的算法,并且在验签时指定用公钥,不要动态从Header读算法。
| 攻击类型 | 受影响算法 | 攻击前提 | 核心防御 |
|---|---|---|---|
| alg:none绕过 | 各类JWT库老版本 | 服务端未禁用none | 显式禁止none,升级库版本 |
| RS256转HS256降级 | 支持多算法且未做白名单 | 可获取RSA公钥 | 算法白名单,固定验签算法 |
| 多次签名注入 | 部分自研验签逻辑 | 验签只取最后一段 | 严格解析三段结构 |
2.2 弱密钥爆破与服务端配置疏漏
如果说算法混淆攻击是"打逻辑层的洞",那弱密钥爆破就是"打密钥管理上的脸"。
JWT做HS256签名时用的HMAC密钥,本质就是一个口令。口令弱不弱,直接决定了Token能不能被伪造。如果密钥是123456、secret、test、password这类弱口令,攻击者截获一个正常Token后,完全可以在本地做离线字典爆破:把字典里的每个词当作密钥,对Header和Payload重新算HMAC签名,跟Token里带的签名比对,对上了就说明找到了密钥。
这个爆破过程有多容易?我拿hashcat测过,跑一个8位纯数字的密钥,RTX 4090上每秒能跑几十亿次,秒级出结果。哪怕密钥稍微复杂点,只要在rockyou字典里,爆破也只是时间问题。更可笑的是,2015年那个知名漏洞就是用了secret当密钥。
爆破成功以后,攻击者拥有和服务器一样的签名能力,可以自己签发任意身份和权限的Token。这比任何绕过都彻底。
除弱密钥外,还有一类常见疏漏是密钥硬编码在代码或配置文件里然后被提交到公共仓库。我做过一次Github敏感信息扫描的演练,用关键词jwt_secret、HS256_KEY之类一搜,全公开仓库里能命中一大堆。密钥一旦泄露,就算当初设置得再复杂也无济于事。
所以密钥管理这一块,建议至少做到三件事:一是密钥强度要够,至少32字节随机值,用openssl rand生成;二是密钥要走配置中心或环境变量管理,绝不入库;三是定期轮换,尤其在怀疑泄露时能立即作废旧密钥。
2.3 敏感信息泄露与Payload明文问题
JWT的Payload是Base64Url编码,这个"编码"没有任何保密能力,解码工具到处都是。我在测试中常常直接拿Burp的Decoder插件把登录接口返回的JWT解出明文,然后翻Payload里有什么可以利用的信息。
常见的糟糕案例包括:把用户的手机号、身份证号、邮箱、密码哈希、内部员工ID全塞进payload;把内部网络路径、服务名等架构信息塞进去;更离谱的是把双因子验证的种子值放进去。这些数据在正常情况下是数据库里的敏感字段,结果一张Token老底全交出去了。
有人可能觉得,payload里的数据反正只有能看到Token的人才能解,攻击者能看到Token不就已经说明会话不安全了吗?这个想法不对。Token在传输链路上会被HTTPS保护,但在客户端本地存储、日志打印、浏览器缓存这些环节,泄露路径多得很。一旦泄露,信息就直接裸奔了。而且JWT的payload是静态的,你没法对单个字段单独加密,放进去就等于公开。
所以实践原则就一句话:JWT里只放必要的最小字段,比如用户ID、角色、过期时间这种必须随Token校验的数据。一切可查、可算、可延迟获取的信息,全部留在服务端数据库里,需要的时候再查。
2.4 重放攻击与刷新Token的设计缺陷
JWT无状态带来一个天然问题:它在有效期内可以无限次重放。攻击者只要有一次机会截获Token,在Token过期之前都能随意冒充用户身份。
很多系统的Token有效期设置得过长,我见过有效期设置30天的,也见过设置成永不过期的。有效期越长,重放攻击的窗口越大,风险成倍增长。但有效期设短了,用户又需要频繁重新登录,体验会很糟糕。这就是为什么要有刷新机制。
业界比较成熟的方案是双Token机制:一个短生命周期的Access Token用于访问业务接口,通常15分钟到2小时;另一个长生命周期的Refresh Token用于换取新的Access Token,通常7天到30天。Refresh Token只存在服务端可校验的安全端点,最好用随机字符串而非JWT格式,一旦检测到异常可以实时撤销。
我在热词里看到有"jwt实现token续签"这条,说明大家在做项目时确实会遇到这个需求。双Token机制之外,还有一个实用的"滑动续期"思路:用户在Token快过期时仍活跃,就自动签发一个过期时间更长的新Token,不打断用户操作。滑动续期要防一种情况——攻击者拿到一个Token后反复在过期前续期,导致会话永不过期。可以在新Token里带一个原Token的iat或jti引用,限制最长续期总时长。这个限制实现起来不复杂,但能堵上一整类风险。
2.5 链路中的其他漏洞点:kid注入、jku参数滥用、算法不匹配
JWT Header里还有一些可被利用的参数,完全取决于服务端实现。
kid(Key ID)是Header里可选的一个键,用来告诉服务端应该用哪一把密钥来做验证。如果开发者偷懒,直接用kid去拼接密钥文件名,比如用fs.readFileSync(keys_dir + '/' + kid),攻击者就可以把kid设置成../../etc/passwd或一个可控路径来读文件,如果再配合SQL注入去数据库里查特定字符串,能把任意可控文件内容当作密钥。更常见的利用是天真的开发者允许从头部位加载密钥,攻击者可以把自己的公钥通过jku参数指向一个攻击者控制的地址,服务端去拉取这个"合法"公钥来验签。这类攻击叫JKU注入。
这些漏洞的门槛都不高,但它们的存在暴露了同一类问题:JWT的Header和Payload都是用户可控输入,任何从这里面取的参数都应当经过严格校验和过滤,绝不能拿来做文件路径拼接、URL加载、SQL查询这类危险操作。
3. 攻击链实战复盘:从拿Token到成为管理员
前面把每种漏洞拆开讲了,可能有同学觉得信息有点散。这一节我串一条攻击链路出来,模拟一次比较典型的JWT漏洞利用,从截获Token到最终提权,把每个环节的攻防决策都标注出来。真实性很高,这套手法我测过不止一家。
3.1 一次完整的攻击链路模拟
场景设定:某内部管理平台的用户登录后,服务端返回一个JWT作为身份凭证,前端的每个API请求都带上这个Token。
第一步,攻击者用Burp抓包,在登录接口的响应里看到了返回的Token。把Token丢进JWT解码器里,Header显示用的是HS256,Payload里有"role":"user"、"uid":"1002"、"exp": 1737500000。到这里,攻击者已经掌握了Token的算法、字段结构、过期时间,以及一个可疑的role字段。这里MitM抓包的前提是目标环境存在某个能让流量经过攻击者的条件,但在内网渗透、公共WiFi、恶意路由这些场景下都可能成真。
第二步,攻击者想知道服务端支持的算法范围,于是手动构造一个Header为alg:none的Token,把role改成admin,直接放行。如果服务端的库还有这个洞,攻击就结束了。但大部分经过验证的方案不会中这招,于是进入下一步。
第三步,攻击者把Token带回家,用hashcat跑本地爆破。当前Payload里的签名是固定的,字典也现成,跑了几分钟就匹配上了密钥,密钥是mysecret123。这一刻起,攻击者已经可以自己签发任意角色的Token。
第四步,攻击者伪造了一个role:admin、有效期48小时的Token,放进Burp的请求里发给管理后台的接口,管理员权限直接拿下,后面的敏感数据导出、越权操作就不细讲了。回头看这条链,密钥弱是第一推动力。
3.2 防御方的视角:哪些环节本可以拦住
复盘这条链路,其实每一环都能设防。
第一步,抓包拿到Token是必然的,但如果整个站点强制HTTPS并且正确配置,公共WiFi上的被动抓包就拿不到明文Token。同时,Token如果不用URL参数传递,而是放在HttpOnly的Cookie里,被恶意脚本窃取的风险会低很多。
第二步,服务端如果明确拒绝alg:none,并且在库中做了MSIgnoreCase之类的类型判断,这个洞就堵住了。更稳妥的是不信任用户提供的算法,在白名单里限定必须使用RS256。
第三步,这是最容易拦住也是最少有人能意识到的一步:密钥强度。只要密钥是32字节随机值,离线爆破基本是不可能完成的任务。用密码学安全的伪随机数生成器出密钥,这是零成本的防御,却能直接斩断伪造链。
第四步,即便攻击者伪造了Token,如果服务端在关键操作上做了二次校验,比如请求敏感接口时校验role字段对应的权限范围是否在用户数据库中被允许,伪造一个role:admin并不可怕,因为数据库里根本没这个人。这意味着,JWT里的角色字段只能作为缓存使用的便利场景,最后一道闸应该留给数据库权限校验。
从攻防视角看,JWT安全不是一个单独的点,它是一整条链路的安全水位。密钥管理、算法白名单、传输加密、服务端二次校验,每一条都做了,才能把水提起来。
4. 防御方案与落地实践:整理一套可行性最高的配置
反复讲"要注意安全"没有味道,这一节直接给可落地的配置和代码思路,照着做就能把前面提到的大部分风险挡在门外。
4.1 密钥管理与算法白名单
密钥管理是JWT安全的地基,这一步做不好,其他都是白搭。在Node.js(bibliothek jsonwebtoken库)里,建议这样处理:
const jwt = require('jsonwebtoken'); // 推荐从环境变量读取密钥,不要硬编码在代码中 const secret = process.env.JWT_PRIVATE_KEY; function signToken(payload) { return jwt.sign(payload, secret, { algorithm: 'RS256', expiresIn: '15m', issuer: 'your-service-name', audience: 'your-app-client' }); } function verifyToken(token) { return jwt.verify(token, publicKey, { algorithms: ['RS256'], issuer: 'your-service-name', audience: 'your-app-client' }); }这段代码能同时挡住两个洞:algorithms参数指定了只允许RS256,就粉碎了RS256转HS256的降级攻击;验签用的publicKey和签名用的secret分开,非对称算法的隔离性就体现出来了。
在Python的PyJWT里同等配置是这样:
import jwt public_key = open("public_key.pem").read() options = {"verify_exp": True, "require": ["exp", "sub"]} try: payload = jwt.decode( token, public_key, algorithms=["RS256"], audience="your-app-client", options=options ) except jwt.InvalidTokenError as e: # 记录日志,返回401 ...关于密钥轮换,很多人会被"换密钥之后旧Token全部失效"这个问题劝退。稳妥做法是引入kid字段和密钥版本表。签发新Token时用最新版本密钥,并在Header里带上kid,验证时通过kid找到对应的密钥做验签。切换期新旧密钥共存,旧Token到期自然退场,整个过程对用户无感。实施下来成本不高,但多数团队没做。
4.2 过期时间与Token续签机制设计
过期时间设多长需要平衡安全与体验。内部管理平台我建议Access Token设15分钟,C端应用放宽到2小时。同时必须引入Refresh Token机制来做无感续签。
前面提过双Token机制,这里给一个标准的接口设计参考:
POST /api/auth/refresh 请求体: { "refresh_token": "xxxxxx" } 校验Refresh Token有效且未过期 生成新的Access Token(重新签名,重置exp) 返回: { "access_token": "xxxxxx", "expires_in": 900 }Refresh Token的存储和校验方式这里有个关键点:不要用JWT格式的Refresh Token。最稳的方案是服务端生成一个随机不可预测的字符串token,和用户ID、过期时间一并入库,每次刷新时查找并校验。检测到Refresh Token被重复使用或者用户主动登出时,服务端可以联动物理删除这个记录,实现真正的"吊销"。这是JWT本身做不到的"基于纯状态"能力。
滑动续期这个方案也可以用,但我在生产环境里被它坑过一次。原来想着"只要用户在操作就续期",结果测试时发现一些无头脚本会自动刷新Token导致永远不过期,等于会话变成永久的。后来加了一条限制:每次续期时读取旧Token的iat,新Token的exp不超过旧Token的iat加最长会话时长(比如12小时)。这样既保住了用户连续操作的体验,又给整个会话画了一条绝对的生命线。
4.3 日志、监控与接口层的防线
JWT攻击有一个特点:密码学上的突破很难被察觉,但业务行为异常往往非常明显。一个普通用户角色突然请求管理员接口、一个Token在几秒内从不同地区登录、某种接口的失败率从1%飙到50%,这些都是很好的告警信号。
我建议至少做三件事。第一,在网关层统一解析JWT并注入到请求上下文,业务代码不要各自解析、各自验证,减少漏验和验法不一致的情况。第二,记录每个Token的jti,在Redis里做短时缓存,虽然不完全阻止重放,但能快速识别出同一个Token在极短时间内并发使用这种异常。第三,对特权接口做双层校验,JWT只是第一道门,进去之后检查用户数据库实际权限是第二道门。
关于日志,这里有一个非常容易踩的坑:把JWT打印到日志系统的明文字段里。我们曾经排查一个问题时,把整个请求体打进了日志,Authorization头也被顺带打了进去。日志平台若权限配置不当或者被拖库,大批量有效Token直接泄露。后来统一改为只记录Token的jti、最后四字符和过期时间,够排障用,泄露面大幅缩小。
4.4 传输层与存储层的正确姿势
Token在传输过程中必须是HTTPS加密的,这是底线。但同一个Token如果在多个地方传递、存储,其他环节的暴露面也要收敛。
存储位置的选择上,最常见的选择是localStorage从写法上最方便,但它是XSS攻击的重灾区。任何一处脚本注入,攻击者都能用localStorage.getItem('token')毫秒级别拿走上线会话。相对更稳妥的做法是把Token放在HttpOnly的Cookie里,这样恶意脚本无法通过document.cookie读取。代价是增加了CSRF攻击的暴露面,需要配合SameSite属性以及CSRF Token来缓解。
一个折中方案是用常规内存变量存储Access Token,Refresh Token放HttpOnly Cookie。页面刷新后Access Token丢失,重新用Cookie中的Refresh Token换取新Token。这个方案的麻烦在于需要额外处理并发刷新,但安全性确实要高一个量级。
5. 常见问题排查与避坑记录
最后这一节,专治生产环境中最常见的JWT问题。我在各种群里回答过大量的同类提问,把典型问题整理成速查,方便大家根据自己的情况定位。
5.1 生产环境高频事故复盘
多个服务密钥不一致导致签名验证失败
一个平台拆了多个微服务,服务A签的Token在服务B验签总是失败。查了半天发现服务A用的是JWT_SECRET_APP环境变量,服务B读的是JWT_SECRET。两边密钥不同,签名自然对不上。这个问题排查周期通常非常长,因为日志里只报signature mismatch,不告诉你两边用了什么参数。建议的排查姿势是做一个内部调试接口,把这个环境变量名的值和密钥的Hash脱敏输出,两相对比就能定位。
自研验签逻辑只校验了Payload
有人觉得"我只要看看这个Token是不是我签发的就行",于是自己写了一段校验:只解码Payload,检查用户ID存在,不看签名。结果就是攻击者随便改几个字段都能生效。这不是JWT的问题,是完全绕过了签名。自研JWT解析一定要用官方或社区维护的库,不要自己Base64解完就信了。
Token放入请求头时带上了Bearer以外的字符串
前端把Token用Authorization: token xxxx给服务端,服务端却按照Bearer xxxx的格式去切分,结果验签的对象包含了多余前缀,签名总是不对。这类低级但高频的问题,往往是联调期间最常见的返工点。建议在前端或网关统一构造标准请求头,服务端预留兼容分支。但注意兼容分支是临时方案,排齐之后要移除,不然又是一个潜在攻击面。
JWT过期时间设置成了操作系统时间戳
我见过一个同学的exp字段直接取Date.now(),他是用毫秒级时间戳去对比秒级时间戳,Token永远提前"3个数量级过期",用户每几秒就掉线一次。统一使用标准exp单位(秒),并且在库或SDK层面开启动态校验,基本不会踩这个坑。
5.2 我在实际项目里重点盯的几个检查项
整理一下我通常在安全评审时逐条过的问题清单,直接可用:
- JWT库版本是否是最新?是否有已披露漏洞?
- 签名算法是否显式限定白名单?是否关闭了alg:none?
- 密钥强度是否达到32字节随机值?是否通过配置中心管理?
- Payload里有没有不必要的敏感字段?
- Access Token过期时间是否过长?是否有Refresh Token机制?
- 验签时是否校验了exp、iat(签发时间)、iss(签发者)、aud(受众)?
- Token在客户端是存储在localStorage还是HttpOnly Cookie?
- 是否全程使用HTTPS?
- 日志里是否打印了完整JWT?
- 特权接口是否有数据库级的权限二次校验?
- kid/jku等Header参数是否做了过滤和控制?
如果一个系统里这十一个问题大部分答案都是正向的,那JWT这一块的攻击面基本已经收得很紧了。如果有一半以上答案是模糊甚至反向的,那这套系统在JWT这边就是"赤手空拳",得赶紧补。
5.3 WebGoat靶场里的JWT练习:上手实践的建议
理论讲再多,都不如自己动手测一遍印象深。OWASP的WebGoat项目里专门有一个JWT漏洞专题,覆盖了算法混淆、密钥爆破、签名绕过、Kid注入等最常见的攻击场景。整个练习就像闯关游戏,每一关卡会提示你当前服务器的JWT实现存在什么问题,你需要手工构造对应的恶意Token去通过校验。
做这个练习时我建议全程用Burp Suite,配合自带的Decoder做Base64编码转换,再动手写点Python脚本辅助爆破和签名构造。做完整个专题,你会对JWT各类漏洞的"手感"有非常直观的认知,比看十篇文档都有用。我带的团队里新来的安全工程师,我都建议他先花两天去过一遍WebGoat的JWT模块,后面做真实项目时上手快得多。
最后说点个人体会
和JWT打了这么多年交道,踩过别人的坑,也踩过自己的坑,我的真实感受是:JWT不是一个"用了就安全"的方案,也不是一个"迟早有洞"的方案,它是一个需要你持续投入正确姿势去守护的方案。它的症结不在标准本身,而在使用者有没有把关键的细节都放在心上。
如果只让我留一条给人印象最深的建议,那就是:永远不要把客户端传入的任何数值当成可信数据,包括Header里的算法、Payload里的角色、request参数里的ID。这句话在JWT场景里尤其成立——因为Token的可信度全部来自Server端的签名与校验策略,一旦策略出现偏差,剩下的就是裸奔。保持对细节的敬畏,比追求花哨的架构更管用。