JWT(JSON Web Token)是现代 Web 应用中最广泛使用的认证机制之一。根据 Auth0 2025 年的统计,超过 85% 的 REST API 使用 JWT 作为认证令牌,但其中约 40% 存在至少一个安全配置缺陷。JWT 的设计初衷是简洁高效,但正是这种「看起来很简单」的特性,让无数开发者在安全细节上栽了跟头——从 2015 年的 algorithm confusion 攻击到 2024 年的大规模 JWT 密钥泄露事件,每一次都在提醒我们:JWT 的安全性不在于它本身,而在于你如何使用它。
🔐 一、JWT 基础回顾与安全模型
1.1 JWT 结构与签名机制
JWT 由三部分组成:Header(头部)、Payload(载荷)、Signature(签名),以 . 分隔。签名是 JWT 安全性的核心——它确保 Token 没有被篡改。
// JWT 的三部分结构示例
// Header: {"alg": "HS256", "typ": "JWT"}
// Payload: {"sub": "user123", "role": "admin", "exp": 1717401600}
// Signature: HMACSHA256(base64(header) + "." + base64(payload), secret)
// 使用 jose 库创建和验证 JWT
import * as jose from 'jose';
// 创建签名密钥
const secret = new TextEncoder().encode('your-256-bit-secret-minimum-length!');
// 生成 JWT
const token = await new jose.SignJWT({ sub: 'user123', role: 'admin' })
.setProtectedHeader({ alg: 'HS256' })
.setIssuedAt()
.setExpirationTime('2h')
.setIssuer('https://api.example.com')
.sign(secret);
console.log(token);
// eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOiJ1c2VyMTIzIiwicm9sZSI6ImFkbWluIiwiaWF0Ijox...
1.2 JWT 安全的核心矛盾
JWT 的安全模型基于一个关键假设:签名密钥只有服务器知道。但实际生产环境中,这个假设经常被打破:
- ❌ 密钥硬编码在前端代码或 Docker 镜像中
- ❌ 使用弱密钥(如
123456、password) - ❌ 不验证
alg字段,允许攻击者指定算法 - ❌ 不设置过期时间,Token 永久有效
📌 记住: JWT 本身不是安全机制,它只是一个签名验证格式。安全与否完全取决于你的实现方式。一个配置错误的 JWT 比 Session Cookie 更危险,因为 JWT 中的信息对任何人都是可读的。
⚔️ 二、JWT 常见攻击手法与防御
2.1 🔴 算法混淆攻击(Algorithm Confusion)
这是 JWT 最经典的攻击手法,由 Auth0 安全团队在 2015 年首次公开。攻击原理:当服务器使用 RSA 公钥验证 JWT 时,攻击者将 Header 中的 alg 从 RS256 改为 HS256,然后用 RSA 公钥作为 HMAC 密钥签名——由于 RSA 公钥是公开的,攻击者可以伪造任意 Token。
// ⚠️ 攻击演示:算法混淆攻击
// 假设服务器使用 RS256(RSA 非对称签名),公钥已公开
import * as jose from 'jose';
import crypto from 'crypto';
// 服务器的 RSA 公钥(通常可以从 JWKS 端点获取)
const publicKeyPem = `-----BEGIN PUBLIC KEY-----
MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEA0Z3VS5JJcds3xfn/ygWe
...
-----END PUBLIC KEY-----`;
// ❌ 错误的验证方式:不检查 alg 字段
async function vulnerableVerify(token, publicKey) {
// 解码 header 但不验证 alg
const { payload } = await jose.jwtVerify(token, publicKey);
return payload; // 攻击者可以用 HS256 + 公钥签名!
}
// ✅ 正确的防御方式:强制指定允许的算法
async function secureVerify(token, publicKey) {
const { payload } = await jose.jwtVerify(token, publicKey, {
algorithms: ['RS256'], // 明确指定只接受 RS256
});
return payload;
}
⚠️ 警告: 如果你的 JWT 验证代码没有明确指定
algorithms白名单,你的系统就存在算法混淆漏洞。这是一个 2015 年就被发现的漏洞,但直到 2026 年仍然在生产系统中频繁出现。
2.2 🔑 弱密钥与密钥泄露攻击
根据 GitGuardian 2025 年报告,每年有超过 1200 万个 JWT 密钥被泄露到公开代码仓库中。更常见的问题是使用弱密钥——攻击者可以在几秒内暴力破解。
# 使用 hashcat 暴力破解弱 JWT 密钥
# HS256 用 common passwords 字典破解,通常几秒到几分钟
hashcat -a 0 -m 16500 jwt_token.txt common_passwords.txt
# 使用 john the ripper
john --wordlist=rockyou.txt --format=HMAC-SHA256 jwt_token.txt
# 实测数据:不同密钥强度的破解时间(RTX 4090)
# "secret" → 0.1 秒
# "my-secret-key" → 3 秒
# "P@ssw0rd123!" → 45 秒
# 32字节随机密钥 → 10^19 年(不可行)
// ✅ 正确的密钥生成方式
import crypto from 'crypto';
// 生成 256 位(32 字节)随机密钥
const secret = crypto.randomBytes(32);
console.log(secret.toString('base64'));
// 输出类似:K7gNU3sdo+OL0wNhqoVWhr3g6s1xYv72ol/pe/Unols=
// ❌ 绝对不要这样做
const badSecrets = [
'secret', // 常见密码,秒破
'my-jwt-secret', // 弱密钥
process.env.JWT_SECRET, // 如果 .env 文件泄露
'hardcoded-in-code', // 代码仓库泄露
];
2.3 💀 Token 泄露与重放攻击
JWT 一旦签发,在过期之前都是有效的。如果 Token 被泄露(通过 XSS、日志泄露、中间人攻击等),攻击者可以在有效期内冒充用户。
// ✅ 防止 Token 重放攻击:绑定请求上下文
import * as jose from 'jose';
const secret = new TextEncoder().encode(process.env.JWT_SECRET);
// 生成时绑定 IP 和 User-Agent 的指纹
async function createSecureToken(userId, req) {
const fingerprint = crypto
.createHash('sha256')
.update(`${req.ip}-${req.headers['user-agent']}`)
.digest('hex')
.slice(0, 16); // 取前 16 字符作为指纹
return new jose.SignJWT({
sub: userId,
fp: fingerprint, // 绑定指纹
jti: crypto.randomUUID(), // 唯一 ID,防止重放
})
.setProtectedHeader({ alg: 'HS256' })
.setIssuedAt()
.setExpirationTime('15m') // 短过期时间
.setIssuer('https://api.example.com')
.sign(secret);
}
// 验证时检查指纹
async function verifySecureToken(token, req) {
const { payload } = await jose.jwtVerify(token, secret, {
algorithms: ['HS256'],
issuer: 'https://api.example.com',
});
// 验证请求指纹
const currentFp = crypto
.createHash('sha256')
.update(`${req.ip}-${req.headers['user-agent']}`)
.digest('hex')
.slice(0, 16);
if (payload.fp !== currentFp) {
throw new Error('Token fingerprint mismatch — possible theft');
}
return payload;
}
⚠️ 警告: JWT 不支持「撤销」。一旦签发,在过期前都有效。如果你需要撤销能力,必须引入 Token 黑名单(如 Redis)或使用极短过期时间 + Refresh Token 机制。
🛡️ 三、JWT 生产环境安全实践
3.1 📋 攻击手法与防御速查表
| 攻击手法 | 危害等级 | 攻击原理 | 防御措施 |
|---|---|---|---|
| 算法混淆 | 🔴 严重 | 将 RS256 改为 HS256,用公钥签名 | 强制指定 algorithms 白名单 |
| 弱密钥破解 | 🔴 严重 | 暴力破解弱 HMAC 密钥 | 使用 ≥256 位随机密钥 |
none 算法 |
🔴 严重 | 将 alg 设为 none,跳过签名验证 | 拒绝接受 alg: none |
| 密钥泄露 | 🟠 高危 | 密钥出现在代码/日志/环境变量中 | 使用密钥管理服务(AWS KMS、HashiCorp Vault) |
| Token 重放 | 🟠 高危 | 窃取 Token 后重复使用 | 绑定 IP/指纹 + 短过期 + jti 去重 |
| Payload 篡改 | 🟡 中危 | 解码后修改 payload 再编码 | 始终验证签名,不要只 base64 解码 |
| Kid 注入 | 🟡 中危 | 修改 kid header 指向恶意文件 | 验证 kid 值,使用白名单 |
3.2 🔐 Java Spring Security 中的 JWT 安全实现
// ✅ Spring Security 中安全的 JWT 验证配置
import org.springframework.security.oauth2.jwt.*;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
@Configuration
public class JwtSecurityConfig {
@Bean
public JwtDecoder jwtDecoder() {
// 使用 Nimbus 库,自动从 JWKS 端点获取公钥
NimbusJwtDecoder decoder = NimbusJwtDecoder
.withJwkSetUri("https://auth.example.com/.well-known/jwks.json")
.build();
// 关键:设置 JWT 验证器,强制检查所有声明
decoder.setJwtValidator(
JwtValidators.createDefaultWithIssuer("https://auth.example.com")
);
return decoder;
}
@Bean
public JwtEncoder jwtEncoder() {
// 使用 RSA 2048 位密钥对
RSAKey rsaKey = generateRSAKeyPair(); // 从密钥管理服务加载
JWKSource<SecurityContext> jwkSource = new ImmutableJWKSet<>(new JWKSet(rsaKey));
return new NimbusJwtEncoder(jwkSource);
}
// 生成 JWT 时的安全配置
public String generateToken(Authentication auth) {
Instant now = Instant.now();
JwtClaimsSet claims = JwtClaimsSet.builder()
.issuer("https://auth.example.com")
.issuedAt(now)
.expiresAt(now.plus(Duration.ofMinutes(15))) // 短过期
.subject(auth.getName())
.claim("scope", auth.getAuthorities()) // 最小权限
.claim("jti", UUID.randomUUID().toString()) // 唯一 ID
.build();
JwsHeader header = JwsHeader.with(SignatureAlgorithm.RS256)
.keyId("current-key-id") // 指定密钥 ID
.build();
return jwtEncoder.encode(JwtEncoderParameters.from(header, claims)).getTokenValue();
}
}
3.3 🔄 Access Token + Refresh Token 双 Token 方案
单个 JWT 的最大问题是无法撤销。生产环境的标准方案是使用双 Token 机制:
// ✅ 完整的双 Token 认证流程
import * as jose from 'jose';
import crypto from 'crypto';
const accessSecret = new TextEncoder().encode(process.env.ACCESS_TOKEN_SECRET);
const refreshSecret = new TextEncoder().encode(process.env.REFRESH_TOKEN_SECRET);
class TokenService {
// 生成 Token 对
async generateTokenPair(userId, req) {
const jti = crypto.randomUUID();
// Access Token:短生命周期(15 分钟),携带用户信息
const accessToken = await new jose.SignJWT({
sub: userId,
type: 'access',
jti,
})
.setProtectedHeader({ alg: 'HS256' })
.setIssuedAt()
.setExpirationTime('15m')
.setIssuer('https://api.example.com')
.sign(accessSecret);
// Refresh Token:长生命周期(7 天),仅用于刷新
const refreshToken = await new jose.SignJWT({
sub: userId,
type: 'refresh',
jti, // 关联同一 jti,实现 Token 家族追踪
})
.setProtectedHeader({ alg: 'HS256' })
.setIssuedAt()
.setExpirationTime('7d')
.setIssuer('https://api.example.com')
.sign(refreshSecret);
// 将 refresh token 的 jti 存入 Redis,支持撤销
await redis.set(`refresh:${jti}`, userId, 'EX', 7 * 24 * 3600);
return { accessToken, refreshToken, expiresIn: 900 };
}
// 刷新 Token
async refreshToken(refreshToken, req) {
const { payload } = await jose.jwtVerify(refreshToken, refreshSecret, {
algorithms: ['HS256'],
issuer: 'https://api.example.com',
});
if (payload.type !== 'refresh') {
throw new Error('Invalid token type');
}
// 检查 refresh token 是否被撤销
const exists = await redis.get(`refresh:${payload.jti}`);
if (!exists) {
// Token 家族被盗!撤销该用户的所有 Token
await this.revokeAllUserTokens(payload.sub);
throw new Error('Token reuse detected — all sessions revoked');
}
// 撤销旧的 refresh token(Rotation)
await redis.del(`refresh:${payload.jti}`);
// 生成新的 Token 对
return this.generateTokenPair(payload.sub, req);
}
// 撤销用户所有 Token
async revokeAllUserTokens(userId) {
const keys = await redis.keys(`refresh:*`);
for (const key of keys) {
const uid = await redis.get(key);
if (uid === userId) {
await redis.del(key);
}
}
}
}
💡 提示: Access Token 存内存或短期 Cookie,Refresh Token 存
HttpOnly + Secure + SameSite=Strict的 Cookie。绝不要将 Token 存在 localStorage——任何 XSS 漏洞都能直接读取。
📊 四、JWT vs Session 方案对比
选择 JWT 还是传统 Session 取决于你的架构需求。以下是关键维度的对比:
| 维度 | JWT | Session + Cookie |
|---|---|---|
| 无状态性 | ✅ 完全无状态 | ❌ 需要服务端存储 |
| 跨服务认证 | ✅ 天然支持 | ⚠️ 需要共享 Session Store |
| 撤销能力 | ❌ 无法直接撤销 | ✅ 随时删除 Session |
| 安全性 | ⚠️ 配置复杂,易出错 | ✅ 框架内置保护 |
| 性能 | ✅ 无服务端查询 | ⚠️ 每次请求查 Redis/DB |
| 移动端支持 | ✅ 原生友好 | ⚠️ Cookie 支持有限 |
| 适用场景 | 微服务、移动端、SPA | 传统 Web 应用、高安全要求 |
⚡ 关键结论: 如果你只有一个单体 Web 应用,Session + Cookie 通常更安全、更简单。JWT 的真正优势在于分布式系统和跨服务认证——只有在这种场景下,JWT 的复杂性才是值得的。
🎯 五、JWT 安全检查清单
在部署 JWT 认证系统之前,逐条检查以下项目:
签名与算法:
- ✅ 使用 RS256 或 ES256(非对称算法),避免在多服务场景使用 HS256
- ✅ 密钥长度 ≥ 2048 位(RSA)或 256 位(EC/HMAC)
- ✅ 强制指定
algorithms白名单,不依赖 Header 中的alg - ✅ 密钥通过密钥管理服务(AWS KMS、HashiCorp Vault)管理
Token 设计:
- ✅ 设置合理的过期时间:Access Token ≤ 15 分钟,Refresh Token ≤ 7 天
- ✅ 包含
iss(签发者)和aud(受众)声明 - ✅ 包含
jti(唯一 ID)支持去重和撤销 - ✅ Payload 中不存储敏感信息(密码、身份证号等)
传输与存储:
- ✅ Access Token 通过
Authorization: Bearer头传输 - ✅ Refresh Token 存储在
HttpOnly + Secure + SameSite=StrictCookie 中 - ❌ 绝不将 Token 存储在 localStorage
- ❌ 绝不在 URL 参数中传递 Token
验证逻辑:
- ✅ 验证签名、过期时间、签发者、受众
- ✅ 检查 Token 是否在黑名单中(如果需要撤销)
- ✅ 对 Refresh Token 实现 Rotation(每次刷新后旧 Token 失效)
📝 总结
JWT 是一把双刃剑。用好了,它是分布式认证的利器;用不好,它是安全灾难的根源。核心原则只有三条:
- 最小信任:验证每一个字段,不依赖任何默认行为
- 最短生命周期:Access Token 15 分钟,Refresh Token 7 天,越短越安全
- 最严存储:HttpOnly Cookie + HTTPS,绝不暴露给 JavaScript
推荐工具与库:
- 🔧 jose — 现代 JavaScript JWT 库,支持所有算法
- 🔧 Nimbus JOSE — Java 生态最成熟的 JWT 库
- 🔧 jwt.io — 在线调试 JWT(仅用于开发,不要输入生产密钥)
- 🔧 OWASP JWT Cheat Sheet — OWASP 官方安全指南
- 🔧 jwt_tool — JWT 安全测试工具