mobile wallpaper 1mobile wallpaper 2mobile wallpaper 3mobile wallpaper 4
3097 字
9 分钟
JWT 安全实践
2026-04-06

JWT 被盗了怎么办?答案是:没办法。JWT 是无状态的——服务器不保存已签发的 Token,自然也无法撤销它。Token 在过期之前,任何持有者都能用它通过验证。更危险的是,JWT 的 Payload 是明文 Base64,签名算法字段 alg 可以被篡改为 none,密钥混淆攻击可以让攻击者用 HMAC 密钥伪造 RSA 签名。JWT 的便利性是一把双刃剑,用对了是高效的身份认证方案,用错了就是安全漏洞的温床。

本章将深入 JWT 的安全实践——从结构到签名到加密,从漏洞到防御到最佳实践。

一、JWT 结构#

1.1 JWT 三部分#

JWT 由 Header、Payload、Signature 三部分组成,用 . 分隔:

xxxxx.yyyyy.zzzzz
Header.Payload.Signature
// Header(头部:声明类型和签名算法)
{"alg": "RS256", "typ": "JWT"}
// Payload(载荷:存放声明/claims)
{"sub": "1234567890", "name": "Zhang San", "iat": 1516239022}
// Signature(签名:保证完整性)
HMACSHA256(base64(header) + "." + base64(payload), secret)
部分编码可读可篡改作用
HeaderBase64URL否(签名保护)声明算法和类型
PayloadBase64URL否(签名保护)存放用户声明
SignatureBase64URL保证完整性
Warning

JWT 的 Header 和 Payload 只是 Base64 编码,不是加密——任何人都可以用 atob() 解码查看内容。永远不要在 JWT 中存储敏感信息(密码、密钥、身份证号等)。

1.2 Base64URL 编码#

JWT 使用 Base64URL 而非标准 Base64,因为 JWT 经常出现在 URL 中:

import base64
import json
# JWT 的 Base64URL 编码
def base64url_encode(data: bytes) -> str:
return base64.urlsafe_b64encode(data).rstrip(b'=').decode('ascii')
def base64url_decode(s: str) -> bytes:
padding = 4 - len(s) % 4
s += '=' * padding
return base64.urlsafe_b64decode(s)
# 解码 JWT 的 Payload
payload_encoded = "eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiaWF0IjoxNTE2MjM5MDIyfQ"
payload = json.loads(base64url_decode(payload_encoded))
print(payload) # {"sub": "1234567890", "name": "John Doe", "iat": 1516239022}

与标准 Base64 的区别:+-/_,去掉 = 填充。

1.3 JWT 的标准声明(Claims)#

声明全称说明是否必须
issIssuer签发者(如 https://auth.example.com推荐
subSubject主题(用户唯一标识)推荐
audAudience接收者(如 https://api.example.com推荐
expExpiration过期时间(Unix 时间戳)必须
nbfNot Before生效时间可选
iatIssued At签发时间推荐
jtiJWT ID唯一标识(用于防重放)可选

1.4 JWT vs Opaque Token#

维度JWTOpaque Token
自包含是(包含用户信息)否(需查询授权服务器)
验证方式验证签名查询数据库/Redis
大小大(1-4KB)小(32-64 字节)
撤销困难(需黑名单)简单(删除记录)
性能无需网络调用每次需查询
微服务友好是(各服务独立验证)否(需回查授权服务)
flowchart LR subgraph JWT模式["JWT 模式"] J_CLIENT["客户端"] -->|"携带 JWT"| J_API["API 网关"] J_API -->|"本地验证签名"| J_SVC1["服务 A"] J_API -->|"本地验证签名"| J_SVC2["服务 B"] Note["无需回查授权服务器"] end subgraph Opaque模式["Opaque Token 模式"] O_CLIENT["客户端"] -->|"携带 Token"| O_API["API 网关"] O_API -->|"每次查询"| O_AUTH["授权服务器"] O_AUTH -->|"用户信息"| O_API end style JWT模式 fill:#e8f5e9,stroke:#2e7d32 style Opaque模式 fill:#fff3e0,stroke:#e65100
Tip

很多生产系统采用混合方案:Access Token 用 Opaque Token(短有效期、易撤销),ID Token 用 JWT(包含用户信息、一次验证)。这样既保持了撤销能力,又避免了每次都查询用户信息。

二、JWS/JWE/JWK#

2.1 JWS(JSON Web Signature)#

JWS 是签名 JWT,提供完整性和认证。JWS 是 JWT 最常用的形式:

flowchart LR HEADER["Header<br/>{alg, typ, kid}"] -->|"Base64URL"| EH["编码后 Header"] PAYLOAD["Payload<br/>{sub, name, exp}"] -->|"Base64URL"| EP["编码后 Payload"] EH -->|"连接 '.'"| SIGN_INPUT["签名输入"] EP -->|"连接 '.'"| SIGN_INPUT SIGN_INPUT -->|"算法签名"| SIG["Signature"] KEY["密钥/私钥"] -->|"算法签名"| SIG style HEADER fill:#e3f2fd,stroke:#1565c0 style PAYLOAD fill:#e8f5e9,stroke:#2e7d32 style SIG fill:#fff3e0,stroke:#e65100
算法说明密钥类型推荐
HS256HMAC-SHA256对称(共享密钥)需共享密钥
HS384HMAC-SHA384对称需注意
HS512HMAC-SHA512对称需注意
RS256RSA-PKCS1v1.5-SHA256非对称推荐
RS384RSA-PKCS1v1.5-SHA384非对称
RS512RSA-PKCS1v1.5-SHA512非对称
ES256ECDSA-P-256-SHA256非对称推荐
ES384ECDSA-P-384-SHA384非对称
EdDSAEd25519非对称最新推荐
none无签名极危险

2.2 JWE(JSON Web Encryption)#

JWE 是加密 JWT,提供机密性。与 JWS 不同,JWE 的 Payload 是加密的:

JWE 五部分(紧凑序列化):
Header.EncryptedKey.IV.Ciphertext.AuthenticationTag
各部分含义:
1. Header: {"alg":"RSA-OAEP","enc":"A256GCM"} — 密钥加密算法 + 内容加密算法
2. EncryptedKey: 用接收者公钥加密的 CEK(Content Encryption Key)
3. IV: 初始化向量
4. Ciphertext: 用 CEK 加密的 Payload
5. AuthenticationTag: AEAD 认证标签
维度JWSJWE
目的签名(完整性+认证)加密(机密性)
可读性Payload 可读Payload 加密
典型用途ID Token、Access Token敏感数据传输
复杂度
常见算法RS256/ES256RSA-OAEP + A256GCM
Note

大多数 OAuth 2.0/OIDC 场景使用 JWS(签名 JWT),因为令牌的完整性比机密性更重要——令牌通过 HTTPS 传输,Payload 中的信息也不是秘密。JWE 主要用于需要在不可信通道中传输敏感数据的场景。

2.3 JWK(JSON Web Key)#

JWK 是 JSON 格式的密钥表示,用于在 JWKS 端点发布公钥:

// RSA 公钥的 JWK 表示
{
"kty": "RSA",
"n": "0vx7agoebGcQSuuPiLJXZptN9nndrQmbXEps2aiAFbWhM78LhWx4cbbfAAtVT86zwu1RK7aPFFxuhDR1L6tSoc_BJECPebWKRXjBZCiFV4n3oknjhMstn64tZ_2W-5JsGY4Hc5n9yBXArwl93lqt7_RN5w6Cf0h4QyQ5v-65YGjQR0_FDW2QvzqY368QQMicAtaSqzs8KJZgnYb9c7d0zgdAZHzu6qMQvRL5hajrn1n91CbOpbISD08qNLyrdkt-bFTWhAI4vMQFh6WeZu0fM4lFd2NcRwr3XPksINHaQ-G_xBniIqbw0Ls1jF44-csFCur-kEgU8awapJzKnqDKgw",
"e": "AQAB",
"kid": "key-2024-01",
"alg": "RS256",
"use": "sig"
}
字段说明必须性
kty密钥类型(RSA/EC/oct)必须
kid密钥 ID(用于密钥轮换)推荐
alg算法推荐
use用途(sig/enc)推荐
n, eRSA 公钥参数RSA 必须
x, y, crvEC 公钥参数EC 必须

2.4 JWKS 端点#

OIDC 提供商通过 JWKS(JSON Web Key Set)端点发布公钥,客户端用它验证 JWT 签名:

# 从 JWKS 端点获取公钥并验证 JWT
import jwt
import requests
def verify_jwt_with_jwks(token: str, jwks_url: str, audience: str) -> dict:
"""使用 JWKS 端点验证 JWT"""
# 1. 解码 Header 获取 kid
header = jwt.get_unverified_header(token)
kid = header.get("kid")
# 2. 从 JWKS 端点获取公钥
jwks = requests.get(jwks_url).json()
public_key = None
for key in jwks["keys"]:
if key["kid"] == kid:
public_key = jwt.algorithms.RSAAlgorithm.from_jwk(key)
break
if not public_key:
raise ValueError(f"Key ID {kid} not found in JWKS")
# 3. 验证 JWT(指定算法白名单!)
payload = jwt.decode(
token,
public_key,
algorithms=["RS256"], # 白名单
audience=audience,
options={"require": ["exp", "sub", "iss"]}
)
return payload
# 使用示例
payload = verify_jwt_with_jwks(
token="eyJhbGciOiJSUzI1NiIs...",
jwks_url="https://auth.example.com/.well-known/jwks.json",
audience="https://api.example.com"
)

三、JWT 常见漏洞#

3.1 alg: none 攻击#

这是最经典的 JWT 攻击——将算法改为 none,删除签名:

sequenceDiagram participant A as 攻击者 participant S as 服务器 A->>A: 修改 Header: {"alg":"none"} A->>A: 修改 Payload: {"sub":"admin","role":"superuser"} A->>A: 删除签名部分 A->>S: 发送伪造的 JWT alt 服务器未校验算法 S->>S: 看到 alg=none,跳过签名验证 S->>A: 认证成功!攻击者获得管理员权限 else 服务器正确校验 S->>S: 白名单检查算法,拒绝 none S->>A: 401 Unauthorized end
# alg:none 攻击演示(仅用于理解原理)
import base64, json
def forge_none_jwt(payload: dict) -> str:
"""构造 alg:none 的伪造 JWT"""
header = base64url_encode(json.dumps({"alg": "none", "typ": "JWT"}).encode())
body = base64url_encode(json.dumps(payload).encode())
return f"{header}.{body}." # 签名部分为空
# 防御:验证时必须白名单算法
def safe_decode(token: str, key) -> dict:
"""安全验证:明确指定允许的算法"""
return jwt.decode(token, key, algorithms=["RS256", "ES256"])

3.2 算法混淆攻击(RS256 → HS256)#

这个攻击利用了 JWT 库的一个设计缺陷——当 Header 中 alg 从 RS256 改为 HS256 时,如果服务端用 RSA 公钥验证,库可能会用公钥作为 HMAC 的对称密钥:

攻击步骤:
1. 攻击者获取服务端的 RSA 公钥(公钥是公开的!)
2. 修改 JWT Header: {"alg": "HS256"}
3. 用公钥作为 HMAC 密钥签名 Payload
4. 服务端验证时:
- 读取 Header 中的 alg=HS256
- 用公钥作为 HMAC 密钥验证
- 验证通过!因为攻击者用了同样的"密钥"(公钥)
防御:服务端必须根据 kid 指定的密钥类型决定算法,不接受 Header 中的 alg
// Go: 安全的 JWT 验证 — 明确指定算法
func verifyToken(tokenString string, publicKey *rsa.PublicKey) (jwt.MapClaims, error) {
token, err := jwt.Parse(tokenString, func(token *jwt.Token) (interface{}, error) {
// 关键防御:检查算法是否匹配预期
if _, ok := token.Method.(*jwt.SigningMethodRSA); !ok {
return nil, fmt.Errorf("unexpected signing method: %v", token.Header["alg"])
}
return publicKey, nil
})
if err != nil {
return nil, err
}
return token.Claims.(jwt.MapClaims), nil
}

3.3 JWK 注入攻击#

攻击者在 JWT Header 中嵌入自己的公钥,如果服务端信任了这个嵌入的密钥,攻击者就可以伪造任意 JWT:

// 恶意 JWT Header:嵌入了攻击者的公钥
{
"alg": "RS256",
"typ": "JWT",
"jwk": {
"kty": "RSA",
"n": "<攻击者的公钥参数>",
"e": "AQAB"
}
}

防御:只使用预先注册的密钥(通过 JWKS 端点或配置文件),不接受 Header 中的 jwk 字段。

3.4 kid 注入攻击#

kid(Key ID)用于选择验证签名的密钥。如果服务端直接将 kid 拼接进 SQL 查询或文件路径,攻击者可以注入恶意值:

攻击 1:SQL 注入
kid: "key-1' UNION SELECT * FROM keys--"
攻击 2:目录遍历
kid: "../../etc/passwd"
攻击 3:kid 指向不存在的密钥
kid: "nonexistent-key" → 服务端可能返回默认密钥或错误
# 防御:kid 白名单验证
def get_key_by_kid(kid: str) -> bytes:
"""安全获取密钥:只接受预定义的 kid"""
ALLOWED_KIDS = {"key-2024-01", "key-2024-02", "key-2024-03"}
if kid not in ALLOWED_KIDS:
raise ValueError(f"Unknown key ID: {kid}")
return load_key_from_kms(kid)

3.5 弱密钥暴力破解#

HS256 使用对称密钥,如果密钥太短或太简单,可以被暴力破解:

# 弱密钥检测工具(概念演示)
import jwt
WEAK_SECRETS = [
"secret", "password", "123456", "jwt_secret",
"your-256-bit-secret", "my_secret_key",
# 常见框架默认密钥
"changeme", "default", "test",
]
def crack_jwt(token: str) -> str:
"""尝试常见弱密钥"""
for secret in WEAK_SECRETS:
try:
jwt.decode(token, secret, algorithms=["HS256"])
return f"破解成功!密钥是: {secret}"
except jwt.InvalidSignatureError:
continue
return "未找到弱密钥"
Warning

HS256 的密钥必须足够长(至少 256 位 / 32 字节)且随机生成。使用 openssl rand -base64 32 生成。永远不要使用可预测的密钥。

3.6 漏洞汇总#

漏洞攻击方式严重性防御
alg: none算法设为 none白名单算法
算法混淆RS256→HS256严格指定算法,检查密钥类型
JWK 注入嵌入攻击者公钥只用预注册密钥
kid 注入SQL/路径注入白名单 kid
弱密钥暴力破解 HS256256 位随机密钥
无过期Token 永不过期设置短有效期
重放攻击截获并重放 Tokenjti + 短有效期
Note

JWT 漏洞的根源几乎都是”服务端信任了客户端提供的信息”——算法、密钥、声明。防御原则:永远不要信任 JWT Header 中的任何字段,所有安全决策都由服务端控制。

四、Token 生命周期管理#

4.1 Access Token + Refresh Token 模式#

生产系统中,JWT 通常配合 Refresh Token 使用:

sequenceDiagram participant C as 客户端 participant AS as 授权服务器 participant RS as 资源服务器 C->>AS: 登录(用户名+密码) AS->>C: Access Token (JWT, 15min)<br/>Refresh Token (Opaque, 7d) Note over C,RS: 正常访问 C->>RS: 请求 + Access Token RS->>RS: 本地验证 JWT 签名 RS->>C: 响应数据 Note over C,RS: Access Token 过期 C->>RS: 请求 + 过期的 Access Token RS->>C: 401 Token Expired C->>AS: Refresh Token AS->>AS: 验证 Refresh Token AS->>C: 新的 Access Token + 新的 Refresh Token Note over C,RS: 用新 Token 继续访问 C->>RS: 请求 + 新 Access Token RS->>C: 响应数据
维度Access TokenRefresh Token
格式JWTOpaque 或 JWT
有效期短(5-15 分钟)长(7-30 天)
用途访问资源获取新 Access Token
存储内存(不持久化)HttpOnly Cookie
撤销等待过期删除服务端记录

4.2 Refresh Token 轮换#

每次使用 Refresh Token 时,签发新的 Refresh Token 并使旧的失效:

# Refresh Token 轮换实现
import secrets
import time
class RefreshTokenManager:
def __init__(self, redis_client):
self.redis = redis_client
def create_refresh_token(self, user_id: str) -> str:
"""创建 Refresh Token"""
token = secrets.token_urlsafe(48)
# 存储到 Redis,设置 7 天过期
self.redis.setex(
f"refresh:{token}",
7 * 24 * 3600,
json.dumps({"user_id": user_id, "created_at": time.time()})
)
return token
def rotate_refresh_token(self, old_token: str) -> tuple:
"""轮换 Refresh Token:旧 Token 失效,签发新 Token"""
# 1. 验证旧 Token 是否存在
data = self.redis.get(f"refresh:{old_token}")
if not data:
raise ValueError("Invalid or expired refresh token")
# 2. 立即删除旧 Token(防止重用)
self.redis.delete(f"refresh:{old_token}")
# 3. 检测重用:如果旧 Token 已被删除但存在于重用记录中
if self.redis.sismember("refresh_reused", old_token):
# 安全事件:有人重用了 Refresh Token
# 应该撤销该用户的所有 Token
self._revoke_all_user_tokens(json.loads(data)["user_id"])
raise ValueError("Refresh token reuse detected")
# 4. 记录旧 Token 到重用检测集合
self.redis.sadd("refresh_reused", old_token)
self.redis.expire("refresh_reused", 30 * 24 * 3600)
# 5. 签发新 Refresh Token
user_data = json.loads(data)
new_token = self.create_refresh_token(user_data["user_id"])
return new_token, user_data["user_id"]

4.3 Token 撤销策略#

JWT 一旦签发就无法”取消签名”——这是 JWT 的根本限制。撤销策略:

策略实现优点缺点
短有效期Access Token ≤ 15 分钟简单用户体验差
黑名单Redis 存储被撤销的 Token jti精确需要额外存储
版本号用户表存储 token_version批量撤销需要查询数据库
密钥轮换更换签名密钥全局撤销所有 Token 失效
# 黑名单撤销实现
class TokenBlacklist:
def __init__(self, redis_client):
self.redis = redis_client
def revoke(self, jti: str, exp: int):
"""撤销 Token:将 jti 加入黑名单,过期时间与 Token 一致"""
ttl = exp - int(time.time())
if ttl > 0:
self.redis.setex(f"blacklist:{jti}", ttl, "1")
def is_revoked(self, jti: str) -> bool:
"""检查 Token 是否被撤销"""
return self.redis.exists(f"blacklist:{jti}")

五、JWT 最佳实践#

5.1 签名与验证#

# Python: 完整的 JWT 签名与验证
import jwt
from cryptography.hazmat.primitives.asymmetric import rsa
from cryptography.hazmat.primitives import serialization
# 生成密钥对
private_key = rsa.generate_private_key(public_exponent=65537, key_size=2048)
public_key = private_key.public_key()
# 签名(指定算法,不接受 Header 中的 alg)
token = jwt.encode(
{
"sub": "1234567890",
"name": "Zhang San",
"exp": 1699999999,
"iat": 1699999099,
"iss": "https://auth.example.com",
"aud": "https://api.example.com",
"jti": "unique-token-id-123"
},
private_key,
algorithm="RS256",
headers={"kid": "key-2024-01"}
)
# 验证(指定算法白名单 + 必须包含的声明)
payload = jwt.decode(
token,
public_key,
algorithms=["RS256"], # 白名单,不接受其他算法
issuer="https://auth.example.com",
audience="https://api.example.com",
options={"require": ["exp", "sub", "iss", "aud"]}
)
// Go: 完整的 JWT 签名与验证
package main
import (
"fmt"
"time"
"github.com/golang-jwt/jwt/v5"
)
func signToken(privateKey []byte, userID string) (string, error) {
key, err := jwt.ParseRSAPrivateKeyFromPEM(privateKey)
if err != nil {
return "", err
}
claims := jwt.MapClaims{
"sub": userID,
"iss": "https://auth.example.com",
"aud": "https://api.example.com",
"exp": time.Now().Add(15 * time.Minute).Unix(),
"iat": time.Now().Unix(),
"jti": "unique-token-id-123",
}
token := jwt.NewWithClaims(jwt.SigningMethodRS256, claims)
token.Header["kid"] = "key-2024-01"
return token.SignedString(key)
}
func verifyToken(tokenString string, publicKey []byte) (jwt.MapClaims, error) {
key, err := jwt.ParseRSAPublicKeyFromPEM(publicKey)
if err != nil {
return nil, err
}
token, err := jwt.Parse(tokenString, func(token *jwt.Token) (interface{}, error) {
// 关键:验证算法类型
if _, ok := token.Method.(*jwt.SigningMethodRSA); !ok {
return nil, fmt.Errorf("unexpected method: %v", token.Header["alg"])
}
return key, nil
}, jwt.WithValidMethods([]string{"RS256"}))
if err != nil {
return nil, err
}
return token.Claims.(jwt.MapClaims), nil
}

5.2 安全检查清单#

检查项要求优先级
算法白名单只接受 RS256/ES256/EdDSA,拒绝 none 和 HS256必须
有效期Access Token ≤ 15 分钟,Refresh Token ≤ 7 天必须
声明验证验证 iss、aud、exp、sub必须
密钥管理KMS/HSM 存储,定期轮换必须
kid 白名单只接受预定义的 kid 值必须
敏感数据不放入 JWT(密码、身份证号等)必须
Token 撤销黑名单或短有效期推荐
HTTPS 传输Token 只通过 HTTPS 传输必须
HttpOnly CookieRefresh Token 存在 HttpOnly Cookie 中推荐
jti 防重放每次签发唯一 jti推荐

5.3 算法选择决策#

flowchart TD START{"需要 JWT?"} -->|"是"| Q1{"多服务验证?"} Q1 -->|"是"| Q2{"有 KMS/HSM?"} Q2 -->|"是"| RS256["RS256 或 ES256<br/>非对称,公钥分发"] Q2 -->|"否"| RS256_BASIC["RS256<br/>最成熟"] Q1 -->|"否"| Q3{"性能敏感?"} Q3 -->|"是"| ES256["ES256<br/>签名更短更快"] Q3 -->|"否"| HS256["HS256(256位密钥)<br/> 需共享密钥"] style RS256 fill:#e8f5e9,stroke:#2e7d32 style RS256_BASIC fill:#e8f5e9,stroke:#2e7d32 style ES256 fill:#e8f5e9,stroke:#2e7d32 style HS256 fill:#fff3e0,stroke:#e65100

五·附、实践:JWT 攻击与防御#

JWT 在 2010 年随 RFC 7519 发布后,因其无状态、自包含的特性迅速取代 SAML Token 成为 Web 认证的事实标准。然而,JWT 的”自包含”设计也带来了独特的安全挑战:服务端必须信任 JWT Header 中的 alg 字段来选择验证算法——而 Header 是攻击者可以篡改的。2015 年前后,alg=none 攻击和算法混淆攻击(RS256→HS256)被大量发现,许多 JWT 库默认接受 none 算法或同时接受对称/非对称算法,导致严重的安全漏洞。现代 JWT 库(如 PyJWT 2.x、java-jwt)已默认防御这些攻击,但理解攻击原理仍然是安全工程师的基本功。

从攻击到防御的演进,核心原则是:永远不要信任客户端提供的任何信息来决定服务端行为——算法选择、密钥标识、签名验证,都必须由服务端白名单控制。

附.1 前置知识#

  • Python 3.8+ + PyJWT 库(pip install PyJWT
  • cryptography 库(pip install cryptography
  • 理解本章前五节的理论内容(JWT 结构、JWS/JWE、常见漏洞)

附.2 alg=none 攻击#

alg=none 攻击是最经典的 JWT 漏洞:攻击者将 Header 中的算法改为 none,移除签名,如果服务端不严格验证算法,就会接受这个无签名 Token:

import jwt, json, base64, time
secret = "super-secret-key"
# 正常签发 JWT
token = jwt.encode(
{"sub": "user123", "role": "user", "exp": int(time.time()) + 3600},
secret, algorithm="HS256"
)
print(f"正常 JWT (HS256):")
print(f" Token: {token[:50]}...")
print(f" 解码: {jwt.decode(token, secret, algorithms=['HS256'])}")
# alg=none 攻击:手动构造 alg=none 的 JWT
header = base64.urlsafe_b64encode(
json.dumps({"alg": "none", "typ": "JWT"}).encode()
).rstrip(b'=').decode()
payload = base64.urlsafe_b64encode(
json.dumps({"sub": "admin", "role": "admin"}).encode()
).rstrip(b'=').decode()
forged_token = f"{header}.{payload}."
print(f"\n伪造 JWT (alg=none):")
print(f" Header: {{'alg': 'none', 'typ': 'JWT'}}")
print(f" Payload: {{'sub': 'admin', 'role': 'admin'}}")
print(f" 伪造 Token: {forged_token[:50]}...")
# 现代库会拒绝 none 算法
try:
decoded = jwt.decode(forged_token, secret, algorithms=["HS256"])
print(" 验证通过!攻击成功!")
except jwt.InvalidAlgorithmError as e:
print(f" 验证失败 — {type(e).__name__}: {e}")
# 不验证签名时可以解码(仅读取,不验证)
decoded = jwt.decode(forged_token, options={"verify_signature": False})
print(f" 不验证签名时解码: {decoded}")
print(f" 如果服务端不验证签名,攻击者可以伪造任意身份!")
正常 JWT (HS256):
Token: eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiJ1c...
解码: {'sub': 'user123', 'role': 'user', 'exp': 1778167624}
伪造 JWT (alg=none):
Header: {'alg': 'none', 'typ': 'JWT'}
Payload: {'sub': 'admin', 'role': 'admin'}
伪造 Token: eyJhbGciOiAibm9uZSIsICJ0eXAiOiAiSldUIn0.eyJzdWIiOi...
验证失败 — InvalidAlgorithmError: The specified alg value is not allowed
不验证签名时解码: {'sub': 'admin', 'role': 'admin'}
如果服务端不验证签名,攻击者可以伪造任意身份!

注意:防御 alg=none 攻击的方法是算法白名单——服务端只接受预期的算法(如 algorithms=["HS256"]),拒绝 none 和任何不在白名单中的算法。

附.3 算法混淆攻击(RS256 → HS256)#

算法混淆攻击利用了 JWT 库对算法类型的自动选择:如果服务端的 algorithms 参数同时包含 RS256 和 HS256,攻击者可以将 RS256 Token 的 alg 改为 HS256,然后用已知的 RSA 公钥作为 HMAC 密钥伪造签名:

from cryptography.hazmat.primitives.asymmetric import rsa
from cryptography.hazmat.primitives import serialization
import hmac as hmac_mod, hashlib
# 服务端使用 RS256
rsa_private = rsa.generate_private_key(public_exponent=65537, key_size=2048)
rsa_public = rsa_private.public_key()
# 正常签发 RS256 JWT
rs_token = jwt.encode({"sub": "user123", "role": "user"}, rsa_private, algorithm="RS256")
print(f"正常 RS256 JWT: {rs_token[:50]}...")
# 提取公钥 PEM(攻击者可以从 JWKS 端点获取)
public_pem = rsa_public.public_bytes(
encoding=serialization.Encoding.PEM,
format=serialization.PublicFormat.SubjectPublicKeyInfo
).decode()
# 攻击:构造 alg=HS256 的 JWT,用公钥作为 HMAC 密钥
header_att = base64.urlsafe_b64encode(
json.dumps({"alg": "HS256", "typ": "JWT"}).encode()
).rstrip(b'=').decode()
payload_att = base64.urlsafe_b64encode(
json.dumps({"sub": "admin", "role": "admin"}).encode()
).rstrip(b'=').decode()
signing_input = f"{header_att}.{payload_att}".encode()
forged_sig = base64.urlsafe_b64encode(
hmac_mod.new(public_pem.encode(), signing_input, hashlib.sha256).digest()
).rstrip(b'=').decode()
forged_rs_token = f"{header_att}.{payload_att}.{forged_sig}"
print(f"\n伪造 HS256 JWT (用公钥作 HMAC 密钥):")
print(f" 伪造 Token: {forged_rs_token[:50]}...")
# PyJWT 2.x 防御了此攻击
try:
decoded = jwt.decode(forged_rs_token, public_pem, algorithms=["HS256"])
print(" 验证通过!算法混淆攻击成功!")
except Exception as e:
print(f" 验证失败 — {type(e).__name__}")
print(" PyJWT 2.x 默认要求 algorithms 参数严格匹配,防御了此攻击")
正常 RS256 JWT: eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiJ1c...
伪造 HS256 JWT (用公钥作 HMAC 密钥):
伪造 Token: eyJhbGciOiAiSFMyNTYiLCAidHlwIjogIkpXVCJ9.eyJzdWIiO...
验证失败 — InvalidKeyError
PyJWT 2.x 默认要求 algorithms 参数严格匹配,防御了此攻击

防御措施:

  1. 算法白名单:只允许预期的算法(如 algorithms=["RS256"]
  2. 不要在 algorithms 列表中同时包含对称和非对称算法
  3. 根据密钥类型自动选择算法:RSA 密钥只允许 RS256/RS384/RS512,EC 密钥只允许 ES256/ES384

附.4 正确的 JWT 签发与验证(ES256)#

ES256(ECDSA + SHA-256)是微服务场景的推荐方案——非对称签名允许各服务用公钥独立验证,无需共享密钥:

from cryptography.hazmat.primitives.asymmetric import ec
import os
# 生成 ECC 密钥对
ec_private = ec.generate_private_key(ec.SECP256R1())
ec_public = ec_private.public_key()
ec_private_pem = ec_private.private_bytes(
encoding=serialization.Encoding.PEM,
format=serialization.PrivateFormat.PKCS8,
encryption_algorithm=serialization.NoEncryption()
)
ec_public_pem = ec_public.public_bytes(
encoding=serialization.Encoding.PEM,
format=serialization.PublicFormat.SubjectPublicKeyInfo
)
# 签发 JWT(含完整标准声明)
now = int(time.time())
es_token = jwt.encode(
{
"iss": "auth-server", # 签发者
"sub": "user123", # 主体
"aud": "api-server", # 受众
"exp": now + 900, # 过期时间(15 分钟)
"iat": now, # 签发时间
"jti": os.urandom(8).hex() # 唯一 ID 防重放
},
ec_private_pem,
algorithm="ES256",
headers={"kid": "key-2024-01"} # 密钥 ID
)
print(f"ES256 JWT: {es_token[:60]}...")
# 验证(含完整声明校验)
decoded = jwt.decode(
es_token,
ec_public_pem,
algorithms=["ES256"],
audience="api-server",
issuer="auth-server"
)
print(f"验证通过 ")
print(f"解码: {json.dumps(decoded, indent=2, default=str)}")
# 过期 Token 验证
expired_token = jwt.encode(
{"sub": "user123", "exp": now - 3600},
ec_private_pem, algorithm="ES256"
)
try:
jwt.decode(expired_token, ec_public_pem, algorithms=["ES256"])
except jwt.ExpiredSignatureError:
print(f"\n过期 Token 验证: 失败 — ExpiredSignatureError")
ES256 JWT: eyJhbGciOiJFUzI1NiIsImtpZCI6ImtleS0yMDI0LTAxIiwidHlwIjoiSldU...
验证通过
解码: {
"iss": "auth-server",
"sub": "user123",
"aud": "api-server",
"exp": 1778164924,
"iat": 1778164024,
"jti": "f929cfcee5fef221"
}
过期 Token 验证: 失败 — ExpiredSignatureError

附.5 密钥轮换实践#

密钥轮换是 JWT 安全的最后一道防线。通过 JWKS 端点发布多个公钥,用 kid 标识不同的密钥版本,实现无缝轮换:

# 两个密钥:旧密钥和新密钥
old_key = ec.generate_private_key(ec.SECP256R1())
new_key = ec.generate_private_key(ec.SECP256R1())
old_pem = old_key.private_bytes(serialization.Encoding.PEM, serialization.PrivateFormat.PKCS8, serialization.NoEncryption())
new_pem = new_key.private_bytes(serialization.Encoding.PEM, serialization.PrivateFormat.PKCS8, serialization.NoEncryption())
old_pub_pem = old_key.public_key().public_bytes(serialization.Encoding.PEM, serialization.PublicFormat.SubjectPublicKeyInfo)
new_pub_pem = new_key.public_key().public_bytes(serialization.Encoding.PEM, serialization.PublicFormat.SubjectPublicKeyInfo)
# 用旧密钥签发 token
old_token = jwt.encode({"sub": "user123"}, old_pem, algorithm="ES256", headers={"kid": "key-v1"})
# 用新密钥签发 token
new_token = jwt.encode({"sub": "user456"}, new_pem, algorithm="ES256", headers={"kid": "key-v2"})
# JWKS 端点模拟
key_map = {"key-v1": old_pub_pem, "key-v2": new_pub_pem}
print("JWKS 端点密钥列表:")
for kid in key_map:
print(f" kid={kid}, kty=EC, crv=P-256")
# 验证:根据 kid 选择对应公钥
for token_name, token in [("旧密钥 Token", old_token), ("新密钥 Token", new_token)]:
header = json.loads(base64.urlsafe_b64decode(token.split('.')[0] + '=='))
kid = header.get("kid")
pub_key = key_map.get(kid)
try:
decoded = jwt.decode(token, pub_key, algorithms=["ES256"])
print(f"{token_name} (kid={kid}): 验证通过 ")
except Exception as e:
print(f"{token_name} (kid={kid}): 验证失败 — {e}")
# 轮换后:旧密钥从 JWKS 移除
print(f"\n轮换后移除旧密钥 (kid=key-v1):")
del key_map["key-v1"]
kid = json.loads(base64.urlsafe_b64decode(old_token.split('.')[0] + '==')).get("kid")
if key_map.get(kid) is None:
print(f" 旧密钥 Token (kid={kid}): 拒绝 — 密钥已从 JWKS 移除")
JWKS 端点密钥列表:
kid=key-v1, kty=EC, crv=P-256
kid=key-v2, kty=EC, crv=P-256
旧密钥 Token (kid=key-v1): 验证通过
新密钥 Token (kid=key-v2): 验证通过
轮换后移除旧密钥 (kid=key-v1):
旧密钥 Token (kid=key-v1): 拒绝 — 密钥已从 JWKS 移除

附.6 实践小结#

攻击/实践原理防御现代库状态
alg=none服务端接受无签名 Token算法白名单,拒绝 nonePyJWT 2.x 默认拒绝
算法混淆RS256→HS256,用公钥伪造不混合对称/非对称算法PyJWT 2.x 密钥类型检查
ES256 签发非对称签名,公钥分发验证 iss/aud/exp/sub推荐方案
密钥轮换kid 标识密钥版本JWKS 端点,旧密钥过期移除生产必备

关键教训:

  1. 永远使用算法白名单algorithms=["ES256"],不要用 algorithms=["HS256", "RS256"]
  2. Access Token 有效期 ≤ 15 分钟:短有效期是最简单的安全措施
  3. 密钥轮换是生产必备:通过 JWKS + kid 实现无缝轮换
  4. 不要在 JWT 中存储敏感数据:JWT 是 Base64 编码,不是加密

六、总结#

上一章深入解读了OAuth 2.0 与 OIDC的内部机制。

维度关键要点
JWT 结构Header.Payload.Signature,Base64URL 编码非加密
标准声明iss/sub/aud/exp 必须验证,jti 防重放
JWS/JWEJWS 签名(完整性),JWE 加密(机密性),大多数场景用 JWS
JWKS通过端点发布公钥,支持密钥轮换
常见漏洞alg、算法混淆、JWK/kid 注入、弱密钥
防御原则白名单算法、不信任 Header、短有效期、密钥类型检查
Token 生命周期Access Token + Refresh Token,轮换防重用
撤销策略黑名单/版本号/密钥轮换,短有效期是最简方案
算法选择微服务用 RS256/ES256,单服务可用 HS256(256 位密钥)

参考#

支持与分享

如果这篇文章对你有帮助,欢迎支持作者或分享给更多人

JWT 安全实践
https://blog.souloss.com/posts/cryptography/jwt-security/
作者
Souloss
发布于
2026-04-06
许可协议
CC BY-NC-SA 4.0

部分信息可能已经过时