JWT 安全最佳实践
概述
JSON Web Token(JWT)是目前分布式系统和微服务架构中最广泛使用的身份认证令牌格式。它基于 JSON 格式定义了一种紧凑、自包含的令牌结构,能够在各方之间安全地传输声明信息。然而,JWT 的安全性问题长期以来一直是业界关注的焦点——从签名算法混淆攻击到密钥泄露,从令牌劫持到刷新机制的设计缺陷,每一个环节都可能成为系统的薄弱点。
本文将从 JWT 的结构出发,系统性地剖析各类安全风险,并提供经过实战验证的防御策略与最佳实践。
JWT 结构概述
一个标准的 JWT 由三个部分组成,以点号(.)分隔:
eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiaWF0IjoxNTE2MjM5MDIyfQ.SflKxwRJSMeKKF2QT4fwpMeJf36POk6yJV_adQssw5cHeader(头部)
Header 通常包含令牌的类型(typ)和使用的签名算法(alg):
{
"alg": "HS256",
"typ": "JWT"
}Payload(负载)
Payload 包含实际的声明(Claims)数据,分为三类:
| 类别 | 说明 | 示例 |
|---|---|---|
| 注册声明(Registered Claims) | 预定义的标准声明 | iss(签发者)、exp(过期时间)、sub(主题)、aud(受众) |
| 公开声明(Public Claims) | 在 IANA JSON Web Token Registry 注册或使用 collision-resistant 命名 | https://example.com/role |
| 私有声明(Private Claims) | 通信双方自定义的声明 | role、permissions |
Signature(签名)
签名用于验证令牌在传输过程中未被篡改。生成方式如下:
HMAC-SHA256(
base64urlEncode(header) + "." +
base64urlEncode(payload),
secret
)对于使用非对称算法的 JWT,签名使用私钥生成,公钥用于验证。
签名算法混淆攻击
签名算法混淆攻击是 JWT 实现中最常见也最危险的安全漏洞之一。攻击者通过操纵 Header 中的 alg 字段,诱使服务端使用不安全的验证路径。
alg=none 攻击
JWT 规范允许 alg 字段设置为 none,表示不进行签名验证。某些 JWT 库在实现中默认接受 none 算法,攻击者只需将 alg 改为 none 并移除签名部分即可伪造任意令牌。
攻击示例:
原始合法令牌:
eyJhbGciOiJSUzI1NiJ9.eyJ1c2VyIjoiYWRtaW4iLCJyb2xlIjoidXNlciJ9.signature攻击者伪造的令牌:
eyJhbGciOiJub25lIn0.eyJ1c2VyIjoiYWRtaW4iLCJyb2xlIjoiYWRtaW4ifQ.如果服务端未对 none 算法进行显式禁用,上述令牌可能被当作有效令牌接受。
RS256 → HS256 密钥混淆攻击
当服务端使用非对称算法(如 RS256)签发令牌,但验证代码未强制指定算法时,攻击者可以:
- 获取服务端的公钥(通常通过
.well-known端点或 JKU 获取) - 将 Header 中的
alg从RS256改为HS256 - 使用获取到的公钥作为 HMAC 密钥对令牌重新签名
- 服务端使用 HS256 算法验证时,会将公钥字符串当作 HMAC 密钥使用,从而导致验证通过
防御措施:
// 不安全的实现方式
const decoded = jwt.verify(token, publicKey); // 依赖令牌中的 alg 字段
// 安全的实现方式
const decoded = jwt.verify(token, publicKey, { algorithms: ['RS256'] }); // 显式指定算法| 防御措施 | 说明 |
|---|---|
| 显式指定算法 | 在 JWT 库中强制指定允许的算法列表,拒绝接受来自令牌头的算法声明 |
禁用 none 算法 | 确保 JWT 库配置中明确禁止 none 算法 |
| 固定算法族 | 不允许在同一系统中混用对称和非对称算法 |
密钥泄露风险与安全存储
密钥是 JWT 安全体系的基石。一旦密钥泄露,攻击者可以签发任意令牌。
常见泄露途径
- 硬编码在源代码中:密钥被直接写在配置文件或代码中,通过版本控制系统泄露
- 不安全的存储介质:密钥存储在数据库、共享存储或日志文件中
- 环境变量泄露:通过调试端点、错误页面或 CI/CD 日志暴露环境变量
- 备份泄露:包含密钥的备份文件被非授权方获取
安全存储最佳实践
对于对称算法(HS256):
# 不推荐
JWT_SECRET: "my-super-secret-key-12345"
# 推荐:使用高熵密钥
JWT_SECRET: "b7f8a2d1e9c34f5a0b6c7d8e9f0a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6e7f8"对于非对称算法(RS256/ES256):
- 私钥仅存储在服务端,通过密钥管理服务(KMS)或硬件安全模块(HSM)管理
- 公钥可通过
.well-known/jwks.json端点安全分发 - 定期轮换密钥对,并为密钥添加唯一标识符(
kid)
密钥生成建议:
# 生成 256 位 HMAC 密钥(推荐使用)
openssl rand -base64 32
# 生成 RS256 密钥对
openssl genrsa -out private.pem 2048
openssl rsa -in private.pem -pubout -out public.pem
# 生成 ES256 密钥对(推荐,性能更优)
openssl ecparam -genkey -name prime256v1 -noout -out private-ec.pem
openssl ec -in private-ec.pem -pubout -out public-ec.pemJWT 过期策略
合理使用时间相关的注册声明是降低 JWT 风险的关键手段。
核心时间声明
| 声明 | 全称 | 含义 | 强制程度 |
|---|---|---|---|
exp | Expiration Time | 令牌过期时间 | 建议强制 |
nbf | Not Before | 令牌生效时间 | 可选 |
iat | Issued At | 令牌签发时间 | 建议使用 |
iss | Issuer | 令牌签发者 | 多服务场景建议强制 |
exp(过期时间)
exp 是最重要的安全声明,定义了令牌何时失效:
{
"exp": 1735689600,
"iat": 1735686000
}过期策略建议:
| 令牌类型 | 建议有效期 | 说明 |
|---|---|---|
| Access Token | 15~30 分钟 | 短生命周期,降低泄露影响 |
| Refresh Token | 7~30 天 | 长生命周期,但需配合旋转机制 |
| ID Token | 1~2 小时 | 仅用于身份信息传递 |
| Email Verification Token | 24 小时 | 一次性使用 |
重要: 服务端在验证令牌时必须严格检查 exp 声明,拒绝过期令牌。同时应注意时钟偏差(clock skew)问题——建议允许最多 30~60 秒的偏差。
nbf(生效时间)
nbf 声明用于指定令牌在某个时间点之前不可用。这在以下场景中非常有用:
- 预签发的计划任务令牌
- 延迟生效的权限变更令牌
iat(签发时间)
iat 声明虽然不直接影响安全性,但可用于:
- 配合令牌黑名单机制,只拒绝在某个时间点之前签发的令牌
- 审计和追踪令牌签发历史
- 检测异常使用模式(如使用远早于当前时间的令牌)
刷新机制
Refresh Token(刷新令牌)机制是现代 OAuth 2.0 和 JWT 认证系统的核心组成部分。一个设计良好的刷新机制可以在保证安全性的同时提升用户体验。
Refresh Token Rotation(刷新令牌旋转)
Refresh Token Rotation 是当前推荐的刷新机制,其核心思想是:每次使用 Refresh Token 获取新的 Access Token 时,同时签发一个新的 Refresh Token 并使旧的 Refresh Token 失效。
序列图:
用户 → 客户端:使用 Refresh Token A
客户端 → 认证服务器:请求新的 Access Token
认证服务器 → 客户端:返回新的 Access Token + 新的 Refresh Token B
客户端:丢弃 Refresh Token A,保存 Refresh Token B实现要点:
// 令牌刷新端点示例
async function refreshToken(oldRefreshToken) {
// 1. 验证旧的 Refresh Token 是否有效
const tokenData = await validateRefreshToken(oldRefreshToken);
// 2. 生成新的 Refresh Token
const newRefreshToken = await generateRefreshToken(tokenData.userId);
// 3. 使旧的 Refresh Token 失效
await revokeRefreshToken(oldRefreshToken);
// 4. 生成新的 Access Token
const accessToken = await generateAccessToken(tokenData.userId);
return { accessToken, refreshToken: newRefreshToken };
}Reuse Detection(重用检测)
如果攻击者窃取了用户的 Refresh Token,合法的用户和攻击者会同时尝试使用同一个 Refresh Token。Reuse Detection 机制用于检测并应对这种场景:
检测逻辑:
- 每个 Refresh Token 在服务端保存一个版本号或状态标记
- 当使用 Refresh Token 时,检查其是否已被标记为"已使用"
- 如果检测到重用,立即执行安全响应:
- 立即撤销该用户所有已签发的 Refresh Token
- 强制用户重新登录
- 记录安全事件并告警
// 重用检测实现
async function detectReuse(refreshTokenId, userId) {
const tokenRecord = await db.refreshTokens.findById(refreshTokenId);
if (tokenRecord.isUsed) {
// 检测到重用!立即撤销该用户所有 Refresh Token
await db.refreshTokens.revokeAllByUser(userId);
// 记录安全事件
await securityLogger.logEvent('REFRESH_TOKEN_REUSE', { userId, refreshTokenId });
// 强制重新登录
throw new SecurityError('Refresh token reuse detected. All sessions revoked.');
}
// 标记当前令牌为已使用
await db.refreshTokens.markAsUsed(refreshTokenId);
}| 策略 | 安全级别 | 用户体验影响 | 实现复杂度 |
|---|---|---|---|
| 纯 Rotation | 中 | 低 | 低 |
| Rotation + Reuse Detection | 高 | 中(极端情况需重新登录) | 中 |
| Rotation + Reuse Detection + 设备指纹 | 最高 | 低 | 高 |
JWT 劫持防护
即使令牌本身没有被篡改,攻击者仍然可能通过中间人攻击、XSS 或 CSRF 等手段窃取 JWT。以下措施可以有效降低令牌劫持风险。
JARM(JWT Additional Security Measures)
JARM 是一组增强 JWT 安全性的补充措施,包括:
- 令牌绑定(Token Binding):将令牌绑定到特定的 TLS 连接
- 发送者约束(Sender-Constrained):限制令牌只能在特定的传输通道中使用
- 演示者证明(DPoP, Demonstrating Proof of Possession):要求客户端证明持有私钥
指纹绑定
将 JWT 绑定到客户端的唯一特征,即使令牌被窃取,攻击者也无法在其他环境中使用:
{
"sub": "user123",
"exp": 1735689600,
"fingerprint": "sha256(clientId+browserFingerprint+deviceId)"
}实现方案:
// 生成指纹令牌
function generateFingerprint(req) {
const components = [
req.headers['user-agent'],
req.headers['accept-language'],
req.ip,
'custom-salt-value'
];
return crypto.createHash('sha256').update(components.join('|')).digest('hex');
}
// 在签发 JWT 时将指纹嵌入 Payload
function issueToken(user, req) {
const fingerprint = generateFingerprint(req);
return jwt.sign(
{ sub: user.id, fingerprint },
secret,
{ expiresIn: '15m', algorithm: 'RS256' }
);
}
// 验证时比对指纹
function verifyToken(token, req) {
const decoded = jwt.verify(token, publicKey, { algorithms: ['RS256'] });
const currentFingerprint = generateFingerprint(req);
if (decoded.fingerprint !== currentFingerprint) {
throw new SecurityError('Token fingerprint mismatch');
}
return decoded;
}IP 绑定
对于安全要求更高的场景,可以将 JWT 绑定到源 IP 地址:
{
"sub": "user123",
"exp": 1735689600,
"ip": "192.168.1.100"
}注意事项:
- IP 绑定在移动网络环境下可能存在误判(用户在不同基站间切换)
- 建议对 IP 变化设定容忍度(如仅记录 IP 段 /24 或 /16)
- 可与指纹绑定组合使用,实现多因素令牌绑定
| 绑定方式 | 安全性 | 用户体验影响 | 适用场景 |
|---|---|---|---|
| 无绑定 | 低 | 无 | 低安全要求的公开服务 |
| 设备指纹 | 中 | 低 | Web 应用 |
| IP 绑定 | 中~高 | 中(IP 变化时) | 企业内网、API 服务 |
| 指纹 + IP 组合 | 高 | 中 | 金融、政务等高安全场景 |
| DPoP | 最高 | 低 | OAuth 2.0 + 敏感 API |
常见攻击与防御
KID 注入攻击
kid(Key ID)是 JWT Header 中的一个可选字段,用于标识签名所使用的密钥。如果 JWT 库在处理 kid 时未做充分校验,攻击者可以构造恶意输入。
攻击方式:
{
"alg": "HS256",
"typ": "JWT",
"kid": "../../../../etc/passwd"
}某些 JWT 库会使用 kid 从文件系统或数据库中查找密钥,攻击者通过路径遍历(Path Traversal)读取系统文件,或将 kid 指向攻击者控制的 URL 来注入恶意密钥。
防御措施:
// 不安全的实现
function getKey(kid) {
return fs.readFileSync(`/keys/${kid}.pem`); // 存在路径遍历风险
}
// 安全的实现
const ALLOWED_KEYS = {
'key-v1': getKey('key-v1'),
'key-v2': getKey('key-v2'),
};
function getKey(kid) {
if (!ALLOWED_KEYS[kid]) {
throw new Error(`Unknown key ID: ${kid}`);
}
return ALLOWED_KEYS[kid];
}| 防御措施 | 说明 |
|---|---|
| 白名单验证 | 将 kid 映射限制在预定义的白名单中 |
| 格式校验 | 拒绝包含路径分隔符(/、\、..)的 kid |
| 密钥缓存 | 将密钥缓存在内存中,避免每次从文件系统或远程加载 |
JKU 欺骗攻击
jku(JWK Set URL)是 JWT Header 中的一个字段,指向一个包含公钥集合的 URL。攻击者可以修改 jku 指向自己控制的服务器,从而使用自己的私钥伪造合法令牌。
攻击流程:
- 攻击者搭建一个受控的 HTTP 服务器,提供包含攻击者公钥的 JWKS 端点
- 构造 JWT,将 Header 中的
jku指向攻击者的服务器 - 使用攻击者的私钥对令牌进行签名
- 如果服务端未对
jku进行严格校验,将会使用攻击者的公钥验证签名
防御措施:
// 安全的 JKU 处理
const TRUSTED_JKU_URLS = [
'https://auth.example.com/.well-known/jwks.json',
];
async function getJWKS(jku) {
if (!TRUSTED_JKU_URLS.includes(jku)) {
throw new Error(`Untrusted JKU: ${jku}`);
}
// 使用 HTTPS 且验证证书链
const response = await fetch(jku, {
headers: { 'Accept': 'application/jwk-set+json' }
});
return response.json();
}密钥混淆攻击
除了前文提到的 RS256 → HS256 混淆外,还存在其他形式的密钥混淆攻击:
- 公钥混淆:如果同一公钥被同时用于多个服务,攻击者可以将在服务 A 上签名的令牌用于服务 B
- 算法降级:攻击者诱使服务端使用比原始算法更弱的验证算法
通用防御原则:
- 始终在服务端代码中硬编码或通过安全配置指定可接受的算法列表
- 不同服务使用不同的密钥或
aud(受众)声明 - 验证令牌时同时校验
iss(签发者)和aud(受众)声明
各语言/JWT 库安全配置建议
Node.js — jsonwebtoken
const jwt = require('jsonwebtoken');
// 签发令牌(安全配置)
const token = jwt.sign(
{
sub: userId,
role: userRole,
iat: Math.floor(Date.now() / 1000),
exp: Math.floor(Date.now() / 1000) + 900 // 15 分钟
},
privateKey, // 使用非对称算法私钥
{
algorithm: 'RS256',
keyid: 'key-v1', // 显式指定 KID
audience: 'https://api.example.com',
issuer: 'https://auth.example.com'
}
);
// 验证令牌(安全配置)
const decoded = jwt.verify(token, publicKey, {
algorithms: ['RS256'], // 显式指定算法,禁止算法混淆
audience: 'https://api.example.com', // 校验受众
issuer: 'https://auth.example.com', // 校验签发者
clockTolerance: 30, // 30 秒时钟偏差容忍
});Python — PyJWT
import jwt
# 验证令牌(安全配置)
try:
decoded = jwt.decode(
token,
public_key,
algorithms=['RS256'], # 显式指定算法
audience='https://api.example.com',
issuer='https://auth.example.com',
options={
'verify_exp': True, # 强制验证过期时间
'verify_iat': True, # 验证签发时间
'require': ['exp', 'iat', 'sub'] # 强制要求这些声明
}
)
except jwt.InvalidTokenError as e:
# 令牌验证失败
handle_error(e)Java — JJWT
// 验证令牌(安全配置)
Jwts.parserBuilder()
.setSigningKeyResolver(signingKeyResolver) // 使用 KeyResolver 而非直接传入密钥
.setAllowedClockSkewSeconds(30) // 时钟偏差容忍
.requireAudience("https://api.example.com") // 校验受众
.requireIssuer("https://auth.example.com") // 校验签发者
.requireSubject(userId) // 校验主体
.build()
.parseClaimsJws(token);Go — golang-jwt
// 验证令牌(安全配置)
token, err := jwt.ParseWithClaims(tokenString, &CustomClaims{}, 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
}, jwt.WithAudience("https://api.example.com"), jwt.WithIssuer("https://auth.example.com"), jwt.WithValidMethods([]string{"RS256"}))Ruby — ruby-jwt
# 验证令牌(安全配置)
decoded_token = JWT.decode(
token,
public_key,
true,
{
algorithms: ['RS256'], # 显式指定算法
aud: 'https://api.example.com', # 校验受众
iss: 'https://auth.example.com', # 校验签发者
verify_expiration: true, # 验证过期时间
leeway: 30 # 30 秒时钟偏差
}
)通用安全配置检查清单
| 配置项 | 推荐设置 | 风险等级 |
|---|---|---|
algorithms | 显式指定为固定值 | 高:算法混淆是最高频漏洞 |
audience | 校验受众声明 | 中:多服务场景下必需 |
issuer | 校验签发者 | 中:多签发者场景下必需 |
clockTolerance | 30~60 秒 | 低:但不可完全忽略 |
require 选项 | 强制 exp、iat、sub | 高:防止遗漏关键验证 |
| 密钥存储 | KMS/HSM/加密环境变量 | 高:密钥泄露即全盘失效 |
kid 处理 | 白名单模式 | 高:防止注入攻击 |
jku 处理 | 白名单 URL + HTTPS | 高:防止远程密钥欺骗 |
总结
JWT 安全涉及面广泛,从令牌的签发、传输、验证到刷新机制,每个环节都可能引入安全漏洞。以下是最关键的安全实践总结:
- 算法安全:始终在服务端显式指定可接受的签名算法,禁用
none算法,防止算法混淆攻击 - 密钥安全:使用高熵密钥,通过 KMS/HSM 管理,定期轮换;非对称算法优于对称算法
- 短期令牌:Access Token 有效期控制在 15~30 分钟以内,降低泄露影响范围
- 刷新安全:实施 Refresh Token Rotation 和 Reuse Detection,检测到重用立即撤销所有会话
- 令牌绑定:将令牌绑定到设备指纹或 IP 地址,防止令牌被窃取后跨环境使用
- 输入校验:对 Header 中的
kid、jku等字段实施白名单验证,防止注入攻击 - 库配置:了解所使用的 JWT 库的安全配置选项,关闭不安全的默认行为
- 日志与监控:记录令牌验证失败事件,监控异常模式,实现实时告警
JWT 本身并非不安全的协议,但错误的实现方式可能导致严重的安全后果。遵循上述最佳实践,可以有效构建安全可靠的 JWT 认证体系。