JWT 被盗了怎么办?答案是:没办法。JWT 是无状态的——服务器不保存已签发的 Token,自然也无法撤销它。Token 在过期之前,任何持有者都能用它通过验证。更危险的是,JWT 的 Payload 是明文 Base64,签名算法字段 alg 可以被篡改为 none,密钥混淆攻击可以让攻击者用 HMAC 密钥伪造 RSA 签名。JWT 的便利性是一把双刃剑,用对了是高效的身份认证方案,用错了就是安全漏洞的温床。
本章将深入 JWT 的安全实践——从结构到签名到加密,从漏洞到防御到最佳实践。
一、JWT 结构
1.1 JWT 三部分
JWT 由 Header、Payload、Signature 三部分组成,用 . 分隔:
xxxxx.yyyyy.zzzzzHeader.Payload.Signature// Header(头部:声明类型和签名算法){"alg": "RS256", "typ": "JWT"}
// Payload(载荷:存放声明/claims){"sub": "1234567890", "name": "Zhang San", "iat": 1516239022}
// Signature(签名:保证完整性)HMACSHA256(base64(header) + "." + base64(payload), secret)| 部分 | 编码 | 可读 | 可篡改 | 作用 |
|---|---|---|---|---|
| Header | Base64URL | 是 | 否(签名保护) | 声明算法和类型 |
| Payload | Base64URL | 是 | 否(签名保护) | 存放用户声明 |
| Signature | Base64URL | 否 | 否 | 保证完整性 |
JWT 的 Header 和 Payload 只是 Base64 编码,不是加密——任何人都可以用 atob() 解码查看内容。永远不要在 JWT 中存储敏感信息(密码、密钥、身份证号等)。
1.2 Base64URL 编码
JWT 使用 Base64URL 而非标准 Base64,因为 JWT 经常出现在 URL 中:
import base64import 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 的 Payloadpayload_encoded = "eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiaWF0IjoxNTE2MjM5MDIyfQ"payload = json.loads(base64url_decode(payload_encoded))print(payload) # {"sub": "1234567890", "name": "John Doe", "iat": 1516239022}与标准 Base64 的区别:+ → -,/ → _,去掉 = 填充。
1.3 JWT 的标准声明(Claims)
| 声明 | 全称 | 说明 | 是否必须 |
|---|---|---|---|
iss | Issuer | 签发者(如 https://auth.example.com) | 推荐 |
sub | Subject | 主题(用户唯一标识) | 推荐 |
aud | Audience | 接收者(如 https://api.example.com) | 推荐 |
exp | Expiration | 过期时间(Unix 时间戳) | 必须 |
nbf | Not Before | 生效时间 | 可选 |
iat | Issued At | 签发时间 | 推荐 |
jti | JWT ID | 唯一标识(用于防重放) | 可选 |
1.4 JWT vs Opaque Token
| 维度 | JWT | Opaque Token |
|---|---|---|
| 自包含 | 是(包含用户信息) | 否(需查询授权服务器) |
| 验证方式 | 验证签名 | 查询数据库/Redis |
| 大小 | 大(1-4KB) | 小(32-64 字节) |
| 撤销 | 困难(需黑名单) | 简单(删除记录) |
| 性能 | 无需网络调用 | 每次需查询 |
| 微服务友好 | 是(各服务独立验证) | 否(需回查授权服务) |
很多生产系统采用混合方案:Access Token 用 Opaque Token(短有效期、易撤销),ID Token 用 JWT(包含用户信息、一次验证)。这样既保持了撤销能力,又避免了每次都查询用户信息。
二、JWS/JWE/JWK
2.1 JWS(JSON Web Signature)
JWS 是签名 JWT,提供完整性和认证。JWS 是 JWT 最常用的形式:
| 算法 | 说明 | 密钥类型 | 推荐 |
|---|---|---|---|
| HS256 | HMAC-SHA256 | 对称(共享密钥) | 需共享密钥 |
| HS384 | HMAC-SHA384 | 对称 | 需注意 |
| HS512 | HMAC-SHA512 | 对称 | 需注意 |
| RS256 | RSA-PKCS1v1.5-SHA256 | 非对称 | 推荐 |
| RS384 | RSA-PKCS1v1.5-SHA384 | 非对称 | |
| RS512 | RSA-PKCS1v1.5-SHA512 | 非对称 | |
| ES256 | ECDSA-P-256-SHA256 | 非对称 | 推荐 |
| ES384 | ECDSA-P-384-SHA384 | 非对称 | |
| EdDSA | Ed25519 | 非对称 | 最新推荐 |
| 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 加密的 Payload5. AuthenticationTag: AEAD 认证标签| 维度 | JWS | JWE |
|---|---|---|
| 目的 | 签名(完整性+认证) | 加密(机密性) |
| 可读性 | Payload 可读 | Payload 加密 |
| 典型用途 | ID Token、Access Token | 敏感数据传输 |
| 复杂度 | 低 | 高 |
| 常见算法 | RS256/ES256 | RSA-OAEP + A256GCM |
大多数 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, e | RSA 公钥参数 | RSA 必须 |
x, y, crv | EC 公钥参数 | EC 必须 |
2.4 JWKS 端点
OIDC 提供商通过 JWKS(JSON Web Key Set)端点发布公钥,客户端用它验证 JWT 签名:
# 从 JWKS 端点获取公钥并验证 JWTimport jwtimport 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,删除签名:
# 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 密钥签名 Payload4. 服务端验证时: - 读取 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 "未找到弱密钥"HS256 的密钥必须足够长(至少 256 位 / 32 字节)且随机生成。使用 openssl rand -base64 32 生成。永远不要使用可预测的密钥。
3.6 漏洞汇总
| 漏洞 | 攻击方式 | 严重性 | 防御 |
|---|---|---|---|
| alg: none | 算法设为 none | 高 | 白名单算法 |
| 算法混淆 | RS256→HS256 | 高 | 严格指定算法,检查密钥类型 |
| JWK 注入 | 嵌入攻击者公钥 | 高 | 只用预注册密钥 |
| kid 注入 | SQL/路径注入 | 高 | 白名单 kid |
| 弱密钥 | 暴力破解 HS256 | 中 | 256 位随机密钥 |
| 无过期 | Token 永不过期 | 中 | 设置短有效期 |
| 重放攻击 | 截获并重放 Token | 中 | jti + 短有效期 |
JWT 漏洞的根源几乎都是”服务端信任了客户端提供的信息”——算法、密钥、声明。防御原则:永远不要信任 JWT Header 中的任何字段,所有安全决策都由服务端控制。
四、Token 生命周期管理
4.1 Access Token + Refresh Token 模式
生产系统中,JWT 通常配合 Refresh Token 使用:
| 维度 | Access Token | Refresh Token |
|---|---|---|
| 格式 | JWT | Opaque 或 JWT |
| 有效期 | 短(5-15 分钟) | 长(7-30 天) |
| 用途 | 访问资源 | 获取新 Access Token |
| 存储 | 内存(不持久化) | HttpOnly Cookie |
| 撤销 | 等待过期 | 删除服务端记录 |
4.2 Refresh Token 轮换
每次使用 Refresh Token 时,签发新的 Refresh Token 并使旧的失效:
# Refresh Token 轮换实现import secretsimport 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 jwtfrom cryptography.hazmat.primitives.asymmetric import rsafrom 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 Cookie | Refresh Token 存在 HttpOnly Cookie 中 | 推荐 |
| jti 防重放 | 每次签发唯一 jti | 推荐 |
5.3 算法选择决策
五·附、实践: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"
# 正常签发 JWTtoken = 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 的 JWTheader = 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 rsafrom cryptography.hazmat.primitives import serializationimport hmac as hmac_mod, hashlib
# 服务端使用 RS256rsa_private = rsa.generate_private_key(public_exponent=65537, key_size=2048)rsa_public = rsa_private.public_key()
# 正常签发 RS256 JWTrs_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 参数严格匹配,防御了此攻击防御措施:
- 算法白名单:只允许预期的算法(如
algorithms=["RS256"]) - 不要在 algorithms 列表中同时包含对称和非对称算法
- 根据密钥类型自动选择算法:RSA 密钥只允许 RS256/RS384/RS512,EC 密钥只允许 ES256/ES384
附.4 正确的 JWT 签发与验证(ES256)
ES256(ECDSA + SHA-256)是微服务场景的推荐方案——非对称签名允许各服务用公钥独立验证,无需共享密钥:
from cryptography.hazmat.primitives.asymmetric import ecimport 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)
# 用旧密钥签发 tokenold_token = jwt.encode({"sub": "user123"}, old_pem, algorithm="ES256", headers={"kid": "key-v1"})# 用新密钥签发 tokennew_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 | 算法白名单,拒绝 none | PyJWT 2.x 默认拒绝 |
| 算法混淆 | RS256→HS256,用公钥伪造 | 不混合对称/非对称算法 | PyJWT 2.x 密钥类型检查 |
| ES256 签发 | 非对称签名,公钥分发 | 验证 iss/aud/exp/sub | 推荐方案 |
| 密钥轮换 | kid 标识密钥版本 | JWKS 端点,旧密钥过期移除 | 生产必备 |
关键教训:
- 永远使用算法白名单:
algorithms=["ES256"],不要用algorithms=["HS256", "RS256"] - Access Token 有效期 ≤ 15 分钟:短有效期是最简单的安全措施
- 密钥轮换是生产必备:通过 JWKS + kid 实现无缝轮换
- 不要在 JWT 中存储敏感数据:JWT 是 Base64 编码,不是加密
六、总结
上一章深入解读了OAuth 2.0 与 OIDC的内部机制。
| 维度 | 关键要点 |
|---|---|
| JWT 结构 | Header.Payload.Signature,Base64URL 编码非加密 |
| 标准声明 | iss/sub/aud/exp 必须验证,jti 防重放 |
| JWS/JWE | JWS 签名(完整性),JWE 加密(机密性),大多数场景用 JWS |
| JWKS | 通过端点发布公钥,支持密钥轮换 |
| 常见漏洞 | alg |
| 防御原则 | 白名单算法、不信任 Header、短有效期、密钥类型检查 |
| Token 生命周期 | Access Token + Refresh Token,轮换防重用 |
| 撤销策略 | 黑名单/版本号/密钥轮换,短有效期是最简方案 |
| 算法选择 | 微服务用 RS256/ES256,单服务可用 HS256(256 位密钥) |
参考
- RFC 7519 — JSON Web Token (JWT)
支持与分享
如果这篇文章对你有帮助,欢迎支持作者或分享给更多人
部分信息可能已经过时






