前阵子有个活儿让我印象很深。一位朋友找到我,说公司内部后台系统被人试出了管理员账号,登录接口本身没什么问题,密码也改得够复杂,但对方依旧能带着有效Token畅通无阻。我没急着下结论,先让他把Token解出来发我看一眼——解出来那一刻我大概就明白了:Header里写着HS256,Payload里躺着普通用户标识,Signature看起来也正常。但当我问他签名密钥是什么,他说配置文件里写着项目名的拼音加一串123,我第一反应是,什么?JWT还有弱口令?
这不是玩笑。很多团队把JWT当成"高级安全方案",觉得用了它就等于上了一把锁。可实际上,JWT的安全性并不取决于Token本身长得有多随机,而是完全取决于那把用来签名的"钥匙"。如果这把钥匙是弱密钥,那Token形同虚设,谁都能伪造。这篇文章我想把JWT弱口令这件事掰开揉碎讲清楚:它藏在哪、怎么被利用、如何彻底堵住。适合写后端接口、做系统设计、负责安全测试或者对认证鉴权体系感兴趣的开发者参考,哪怕你是刚接触JWT的新手,只要能看懂Base64,这篇文你就啃得动。
1. JWT弱口令这口锅,到底该谁来背
1.1 先从JWT的身份结构说起
JWT全称JSON Web Token,简单说就是一段经过签名的JSON数据载体,用来在前后端之间安全地传递用户身份和授权信息。它长这样:
eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ1c2VySWQiOjEwMDEsInJvbGUiOiJhZG1pbiJ9.4ZzRzMKlBmV3yZ8s9d7UQgXxzXQVU7Jc0Y0Q7Kj0Y1A整串Token用英文句点分隔成三段:Header、Payload、Signature。Header里一般声明签名算法和Token类型,Payload里放用户ID、角色、过期时间这类自定义声明,Signature则是用密钥对前两段内容做签名运算的结果。
前端拿到Token后,每次请求接口都带上它,后端取下来验签,验过了就认为用户身份可信。这套机制本身没问题,问题出在很多人误解了Signature的作用。Signature不是加密,它只是完整性校验。它保证的是"内容没被篡改",而"内容代表谁"这件事,完全依赖你签名的密钥是否只有服务端知道。
用一句话概括:JWT的安全强度直接等于签名密钥的安全强度。密钥弱,Token就是一纸空文。
1.2 弱口令和弱密钥,说的其实是同一件事
"弱口令"这个词大家平时听得最多的是在账号密码场景里,比如密码设成123456、admin、password。JWT这里说的弱口令,指的就是签名密钥也犯了同样的毛病:太短、太常见、太容易被猜出来。
我在测试中见过很多离谱的密钥。有直接用单词"secret"的,有用"key"的,有把项目名全拼写上去的,有用company123这种组合的,还有直接抄开源项目默认配置忘了改的。这些密钥放在字典里都是第一批被命中的对象。
可能有开发同学会说,我用的密钥别人怎么会知道?但你要明白,JWT的Header和Payload是明文,只要拿到Token,攻击者可以立即解出这两段内容。而签名部分的处理方式,取决于密钥的强度。如果一个密钥存在于常用字典中,或者长度只有六位纯数字,那攻击者就可以在本地反复尝试签名,比对是否一致。这只是时间问题,而且是分分钟的事。
我在《OWASP API安全测试》相关实践里也多次看到,JWT弱密钥位列常见API漏洞前几名。它不是小众冷门问题,而是真实项目里反复出现的高发问题。
1.3 先搞懂Signature是怎么算出来的
拿最常见的HS256来说,它的签名过程是:
Signature = HMAC-SHA256( base64UrlEncode(Header) + "." + base64UrlEncode(Payload), Secret )这里的关键点是HMAC-SHA256是一种对称运算,同一个密钥既能签名也能验签。也就是说,服务端和客户端事实上共享着同一个"秘密"。如果这个秘密被攻击者拿到或猜出来,他就能在本地用一模一样的算法,伪造任何身份的Token。
这是HS256与其他非对称算法最大的区别。RS256、ES256这些算法用的是私钥签名、公钥验签的方式,私钥只在服务端手里,客户端拿到的公钥只能验证不能伪造。所以在条件允许的情况下,很多安全建议都会推荐优先用非对称算法。但如果对称密钥足够随机、足够长,HS256也照样能用,前提是你别把密钥搞成弱口令。
2. 攻击者是怎么一步步盯上JWT弱密钥的
2.1 先解码,看看有没有便宜可占
攻击者拿到一段JWT后,第一件事永远是解码。因为Header和Payload都只是Base64Url编码,根本没加密,任何在线工具、任何三行Python代码都能解出来。
解完Header,看alg字段是什么。解完Payload,看里面有没有userId、role、isAdmin、exp这类信息。这一步决定了后续有没有操作空间。如果Payload里直接写着"role":"admin",而签名又是用弱密钥做的,那攻击者基本就知道该怎么干了:改role为admin,重新算个签名就行。
我见过更野的情况,某些后端代码连签名校验都没做,只解了Payload就直接信任里面字段。这种场景下根本不用破密钥,把Payload里的管理员标志位改成1,Token直接就变成管理员了。所以看到JWT里的关键身份字段被明文存放,本身就值得警惕。
2.2 弱密钥为什么逃不过字典的扫荡
如果服务端确实做了验签,攻击者就会进入第二步:尝试破解密钥。
在HS256场景下,攻击者手里已经拿到了完整的Token,其中包含正确的Header、Payload和Signature。他要做的就是用候选密钥对Header和Payload重新做HMAC-SHA256签名,跟Token里的Signature比对。一致,说明猜中了。
专业工具有很多,比如Hashcat就对JWT专门开了模式。网上能找到的示例大概是:
hashcat -m 16500 jwt_token.txt wordlist.txt模式16500对应JWT HS256(HS384是16501,HS512是16502)。它的原理就是把常见口令、密码组合、默认密钥这些词典内容挨个试过去。如果你的密钥是"secret"、"password"、"admin888"这种级别的,那基本上秒破。
我自己做授权测试时也验证过,一份百万级的常用密码表,跑完可能也就几分钟。如果密钥再长一点、随机一点,比如20位以上随机字符,这种字典跑法就没什么用了,会变成纯粹的暴力搜素,难度指数级上升。
2.3 算法混淆攻击,防不胜防
弱密钥之外,JWT还有一类经典问题叫算法混淆攻击。它利用的是服务端在验签时对alg字段的过度信任。
有些服务端代码没有校验算法类型,直接按Header里声明的算法去验签。于是攻击者可以做两件极为鸡贼的事:
第一,把alg改成"none"。这是JWT早期设计留下的一个后门开关,表示"不签名"。如果服务端还在兼容这个写法,攻击者只要构造一个空签名的新Token,把Payload替换成管理员,系统就可能直接放行。我见过很多老项目里有类似漏洞,后来库更新了还好,没更新的至今还漏着。
第二,把算法从RS256改成HS256。原本公钥验签的非对称机制,被改成对称密钥验签后,有些实现会直接用公钥内容当作HS256的密钥去验签。而公钥往往是公开的,任何人都能拿到。攻击者把公钥当密钥重新签名一份Token,服务端拿同一个公钥去验,居然能通过。这个坑特别隐蔽,如果不限定算法白名单,很容易踩进去。
2.4 缺少必要声明校验,相当于城门大开
还有些JWT实现,签名密钥设置得够复杂,但校验逻辑不完整。比如只验Signature,不验exp过期时间;或者验了exp,却不校验iss、aud这两个标识字段。这会导致什么结果?攻击者拿到一个合法Token,即使解不开密钥,他也可以把有效期内Token的Payload内容改动后,导致服务端逻辑错乱,或者在Token过期后,某些校验逻辑不严格的接口继续接受它。
典型的情况是,后端在验签通过后只把Payload里解析出来的用户ID拿去查库,这个ID可以篡改成其他人的ID,如果没做用户归属校验,那就是越权漏洞。JWT的每个声明字段都不是摆设,exp管过期,nbf管生效时间,iss管签发方,aud管接收方,全部该验就验,少一样就多一分被钻空子的风险。
3. 现场拆解:从拆Token到伪造管理员身份的全过程
3.1 在合规前提下准备一个测试环境
先说一句必要的边界强调:下面这套流程只适合在你自己负责或被授权的测试环境里做。放到别人的系统上就是违法的事,任何人都不该越界。
我的做法通常是在本地搭一个小型演示服务,或者使用某公司的内部测试环境。某后端系统模拟项目X,登录接口签发JWT,密钥故意设成常见弱密钥形式,用来演示攻击路径。这样的环境干净、可控制,不会影响任何人。
3.2 第一步:拆开Token看看里面是什么
假设我从前端抓包拿到一段Token:
eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ1c2VySWQiOjEwMDEsInJvbGUiOiJ1c2VyIiwiZXhwIjoxNzM1NjgwMDAwfQ.xxxx我用Python解码一下Header和Payload:
import base64 def b64url_decode(s): padding = '=' * (-len(s) % 4) return base64.urlsafe_b64decode(s + padding) token = "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ1c2VySWQiOjEwMDEsInJvbGUiOiJ1c2VyIiwiZXhwIjoxNzM1NjgwMDAwfQ.xxxx" header_segment, payload_segment, signature_segment = token.split('.') print(b64url_decode(header_segment)) print(b64url_decode(payload_segment))解码结果大概是这样:
{"alg":"HS256","typ":"JWT"} {"userId":1001,"role":"user","exp":1735680000}现在信息很清楚了,用的是HS256,Payload里直接带着用户ID和角色,过期时间也超过当前时间。攻击者已经知道:如果要伪造管理员,只需要把role改成admin,然后重新算签名。
3.3 第二步:用字典方式破解弱密钥
接下来对Signature做本地破解。为了让过程更直观,我写一段小脚本,模拟对候选密钥逐个验签的过程:
import hmac import hashlib import base64 def jwt_sign(header, payload, secret): message = header + "." + payload sig = hmac.new(secret.encode(), message.encode(), hashlib.sha256).digest() return base64.urlsafe_b64encode(sig).rstrip(b'=').decode() header = "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9" payload = "eyJ1c2VySWQiOjEwMDEsInJvbGUiOiJ1c2VyIiwiZXhwIjoxNzM1NjgwMDAwfQ" target_sig = "xxxx" wordlist = ["secret", "password", "admin", "qwerty", "123456", "company123", "test", "key", "jwt", "letmein"] for word in wordlist: sig = jwt_sign(header, payload, word) if sig == target_sig: print(f"[+] 密钥命中: {word}") break else: print("[-] 常用字典未命中,换更大的字典")真实的破解流程用的是更大规模的字典,可能几十万甚至上百万行。但我敢说,如果系统里用的是"secret"或者项目名拼音这种,基本几分钟之内就会被跑出来。弱密钥在词典面前没有任何抵抗力可言。
3.4 第三步:伪造一个新的管理员Token
密钥被命中之后,伪造Token就是顺理成章的事:
import json import time header = {"alg": "HS256", "typ": "JWT"} payload = { "userId": 1001, "role": "admin", "exp": int(time.time()) + 3600 } def base64url_encode(data): return base64.urlsafe_b64encode(json.dumps(data, separators=(',', ':')).encode()).rstrip(b'=').decode() h = base64url_encode(header) p = base64url_encode(payload) s = jwt_sign(h, p, "company123") forged_token = f"{h}.{p}.{s}" print(forged_token)把这个伪造Token放到接口请求里,如果服务端验签逻辑完整,这Token签名是合法的,role又变成了admin,后端权限判断直接放行。这正是JWT弱口令问题的杀伤力所在:它让攻击者不是"绕过验证",而是"合法地伪装成任意身份"。
影响范围有多大?如果Payload里有userId字段,攻击者可以改ID模拟任意用户;如果有权限字段,直接提权到管理员。如果Token里面还有部分业务标识,比如租户ID、店铺ID,那更惨,横向越权加纵向提权一起打包。
4. 加固方案:从密钥生成到验签流程全量落地
4.1 密钥选型,别再用你的项目名当钥匙
这是最重要的一条。不管算法是HS256还是其他对称签名,密钥的强度必须达到"随机不可猜"的程度。
我推荐的密钥生成方式是直接用加密安全的伪随机源生成。比如用OpenSSL:
openssl rand -base64 48或者用Python:
import secrets import base64 key = base64.urlsafe_b64encode(secrets.token_bytes(48)).decode() print(key)这样做出来的密钥是64个左右的随机字符,组合空间足够大,字典攻击和暴力破解都不现实。要特别注意,不能用普通随机函数,比如某些语言里的Math.random、rand()生成密钥,它们的随机种子强度不足,有被预测的风险。
密钥不要硬编码在源码里,也不要写进前端代码、更不要出现在日志里。放在环境变量、密钥管理服务或独立的配置中心里,并且设置合理的权限。
4.2 算法选择与校验白名单
如果你是从零设计系统,我建议优先考虑非对称算法,比如RS256或ES256。它们各自有特点:RS256是RSA签名,兼容性极好,几乎所有JWT库都支持;ES256用的是椭圆曲线,签名更短,性能更好,但对库的成熟度和随机数安全性要求更高。使用非对称算法时,服务端持有私钥,客户端只有公钥,攻击者即使拿到公钥也没法伪造Token,安全性天然高一层。
如果因为历史原因只能用HS256,那就必须确保密钥是上面说的高强度随机值。同时,在服务端验签时要做算法白名单校验:
- 只允许"HS256"或指定的某几个算法。
- 如果代码里支持了RS256等非对称算法,就绝不能让同一个验签函数接受HS256。
- 遇到"none"算法直接拒绝。
这里还要提醒一句,JWT库如果版本太老,很多默认行为并不安全。尽量使用维护活跃的库,并确保升级到最新版本,很多算法混淆漏洞都是在库更新里面被修复的。
4.3 验签之外,该验的声明一个都不能少
签名验证只是第一步,验签通过了不代表一切OK。后面的声明校验才是决定权限是否准确的关键:
- exp:过期时间,必须校验。很多库默认不校验,要显式开启。
- nbf:生效时间,防止Token被提前使用。
- iss:签发方标识,确定为信任方签发的Token。
- aud:接收方标识,防止一个系统签发的Token被用到另一个系统。
- 业务字段:如userId、role,要以服务端查库结果为准,不要盲目信任Token里带的业务数据。
我见过不下五次案例,密钥很强但忘了校验exp,结果Token永不过期,被攻击者拿到后当成长效后门来用。这部分逻辑写起来成本极低,千万别省。
4.4 过期时间、刷新机制与密钥轮换
Token的有效期设置也有讲究。JWT有一个天然劣势:签发之后,如果要让它失效,你得在服务端维护黑名单。所以有效期越长,被泄露后风险窗口越大。
我的建议是所有Token有效期按业务需要设短。普通用户会话可以是15分钟到2小时,管理后台还可以更短。如果业务需要保持登录状态,提供刷新Token机制,由服务端来控制续期流程,而不是简单把JWT的过期时间延长到几天甚至几周。
密钥轮换也值得定期做。HS256场景下,轮换意味着所有用旧密钥签的Token会失效,所以要么提前双密钥并行,在Header里加kid标识,用kid选择对应密钥验签;要么接受轮换时用户需要重新登录的现实。日常安全审计中,定期轮换密钥能有效降低长期泄露的风险。
5. 常见问题与排查技巧实录
5.1 一张实用的常见错误对照表
我整理了这些年在项目里看到的JWT安全隐患,按出现频率做个表,大家可以对照排查:
| 错误类型 | 具体表现 | 处置方式 |
|---|---|---|
| 弱密钥 | 用secret、项目名、123456等 | 立刻生成高强度随机密钥并轮换 |
| 硬编码密钥 | 密钥写在源码、配置文件中被提交 | 迁移到环境变量或密钥管理平台 |
| 不校验签名 | 只解析Payload就信任内容 | 补上完整验签逻辑 |
| 不校验过期时间 | exp字段形同虚设 | 显式开启过期校验 |
| 不校验算法白名单 | 接受none或算法混淆 | 服务端固定算法白名单 |
| Payload存敏感数据 | 明文存手机号、身份证等 | 只存必要字段,敏感信息放服务端 |
| Token记录日志 | 日志中出现完整Token | 日志脱敏,只记录摘要或ID |
这七类问题里,前四类在高发故障里占比最大。尤其是"弱密钥"和"不校验签名"这两条,基本属于一抓一个准。
5.2 一次真实的排查复盘记录
有次我排查一个出问题的接口,现象是普通用户调接口能拿到本不该有的数据。第一轮看代码,签名验证有,密钥长度也够,看上去没问题。但仔细读了验签之后那一段,发现代码只校验了Signature,没校验Payload里的userId和当前登录用户是否一致,直接把Token里的userId拿去做数据库查询了。
这就是典型的"认证过了但没做授权"。攻击者不需要破解密钥,只需要把自己Token里的userId改成管理员ID就行。因为Payload是明文的,谁都能改,能不能生效取决于是否校验签名和是否校验用户归属。这类问题光靠强化密钥解决不了,必须把Token里的身份信息与服务端会话建立绑定。
所以排查JWT问题的时候,我的思路都是按三层走:第一层,密钥是否弱;第二层,验签逻辑是否完整;第三层,授权判断是否独立于Token内容。这三层任何一层出问题,整个认证体系都等于白搭。
5.3 日常监控与主动检测
别等出事再排查,日常监控一样有用。前端日志里不要打印完整Token,服务端日志也一样,打一个短ID或者哈希前缀就够了,否则日志一泄露等于Token批量泄露。
在网关或统一鉴权层加告警规则,比如同一Token在极短时间内大量被使用、频繁出现签名解码失败、同一个Token重复在不同IP上出现,这些都可能是被篡改或泄露的信号。如果监控系统支持,对异常伪造尝试IP做临时封禁,能有效拖慢攻击节奏。
有条件的话,可以定期对测试环境做一次自动化扫描,专门用弱密钥词典尝试破解自己签发出来的Token。这种主动验证的意义很大,相当于自己先做一遍测试,远比事后被攻击者试出来要好得多。
就我个人在实际项目里的体会来说,JWT弱口令问题给我的最大提醒是:安全设计不能想当然,更不能只靠"用了某个技术就安全了"的直觉。每一层的校验逻辑都要落到细节上,密钥怎么存、算法怎么配、声明怎么验、日志怎么记,这些看似琐碎的点,最终决定的是一个系统的真实安全水位。以后你再看到项目里用JWT,别急着说高级,先问一句:密钥够不够强,验签做没做全。问清楚这两件事,至少能拦住一半的麻烦。