JWT 认证安全实践与常见漏洞
JWT 是 RESTful API 最常用的无状态认证方案,但算法混淆、签名缺失、弱密钥等问题会让 Token 认证形同虚设。
本文是 Java Web 认证授权安全系列 的第四篇。
4.1 算法混淆攻击
攻击原理:服务端同时接受 RS256(非对称)和 HS256(对称)算法时,攻击者可将算法改为 HS256,然后用公开的公钥作为 HMAC 密钥签名 Token。
// ❌ 危险:同时接受多种算法
@Bean
public JwtDecoder jwtDecoder() {
return NimbusJwtDecoder
.withJwkSetUri("https://auth.example.com/.well-known/jwks.json")
.jwsAlgorithms(algs -> {
algs.add(JWSAlgorithm.RS256);
algs.add(JWSAlgorithm.HS256); // ← 危险!
})
.build();
}
// ✅ 正确:只接受单一算法
@Bean
public JwtDecoder jwtDecoder() {
return NimbusJwtDecoder
.withJwkSetUri("https://auth.example.com/.well-known/jwks.json")
.jwsAlgorithm(JWSAlgorithm.RS256)
.build();
}
4.2 alg: none 攻击
某些库在配置不当时接受未签名 Token:
{"alg": "none"}
{"sub": "admin", "role": "ADMIN"}
// 无签名部分
Java 常见错误:
// ❌ parseClaimsJwt 不验证签名!
Claims claims = Jwts.parser().parseClaimsJwt(token).getBody();
// ✅ parseClaimsJws 强制验证签名
Claims claims = Jwts.parser()
.setSigningKey(secretKey)
.parseClaimsJws(token) // ← 带 's'
.getBody();
// ❌ catch 范围过大,掩盖 UnsupportedJwtException
public boolean validateToken(String token) {
try {
Jwts.parser().setSigningKey(SECRET_KEY).parseClaimsJws(token);
return true;
} catch (Exception e) { // ← 太宽泛
return false;
}
}
修复:分别处理 UnsupportedJwtException、SignatureException、ExpiredJwtException。
4.3 弱密钥与密钥管理
// ❌ 弱密钥 / 硬编码
String secret = "mySecretKey"; // 太短
String secret = "abc123def456ghi789jkl012mno345pq"; // 硬编码
// ✅ 正确做法
Key key = Keys.secretKeyFor(SignatureAlgorithm.HS256); // 自动生成 256 位
// 或从 Vault/KMS 安全存储读取
CVE-2024-31033:JJWT 在处理 setSigningKey() 时某些字符被忽略。建议:使用 byte[] 传参,升级最新版。
4.4 Token 生命周期与吊销
String token = Jwts.builder()
.setSubject(userId)
.setIssuer("https://auth.example.com")
.setAudience("api.example.com")
.setIssuedAt(new Date())
.setExpiration(new Date(System.currentTimeMillis() + 900_000)) // 15分钟
.signWith(privateKey, SignatureAlgorithm.RS256)
.compact();
验证:
Jws<Claims> jws = Jwts.parserBuilder()
.setSigningKey(publicKey)
.requireIssuer("https://auth.example.com")
.requireAudience("api.example.com")
.build()
.parseClaimsJws(token);
吊销策略:JWT 无状态无法撤销,需配合:
- 短过期 + Refresh Token 轮换
- Redis 黑名单存储已吊销 Token ID
- 关键操作强制重新认证
4.5 JWT 安全检查清单
@Component
public class JwtTokenValidator {
private final SecretKey secretKey;
private final RedisTemplate<String, String> redis;
public Claims validateAndParse(String token) {
String tokenId = extractTokenId(token);
if (Boolean.TRUE.equals(redis.hasKey("token:revoked:" + tokenId))) {
throw new SecurityException("Token revoked");
}
return Jwts.parserBuilder()
.setSigningKey(secretKey)
.requireIssuer("auth.example.com")
.requireAudience("api.example.com")
.setAllowedClockSkewSeconds(30)
.build()
.parseClaimsJws(token) // ← parseClaimsJws!
.getBody();
}
}
4.6 JWE 加密 Token(补充)
JWT 的 payload 是 Base64 编码,不是加密。任何人都能看到其中内容:
// 这个 payload 任何人拿到 Token 即可解码阅读
{"userId": 123, "role": "admin", "internalDeptId": 42}
JWE(JSON Web Encryption) 提供真正的加密:
// ✅ JWE 加密 Token — 内容不可见
public String generateEncryptedToken(User user) throws Exception {
// 1. 构建 JWT Claims
JWTClaimsSet claims = new JWTClaimsSet.Builder()
.subject(user.getId().toString())
.claim("role", user.getRole())
.issueTime(new Date())
.expirationTime(new Date(System.currentTimeMillis() + 900_000))
.build();
// 2. 创建 JWE 头部
JWEHeader header = new JWEHeader(
JWEAlgorithm.RSA_OAEP_256, // 密钥加密算法
EncryptionMethod.A256GCM // 内容加密算法
);
// 3. 加密
EncryptedJWT jwt = new EncryptedJWT(header, claims);
RSAEncrypter encrypter = new RSAEncrypter(publicKey);
jwt.encrypt(encrypter);
return jwt.serialize();
}
// 解密
public Claims decryptAndValidate(String encryptedToken) throws Exception {
EncryptedJWT jwt = EncryptedJWT.parse(encryptedToken);
RSADecrypter decrypter = new RSADecrypter(privateKey);
jwt.decrypt(decrypter);
// 然后再做常规 JWT 校验
return jwt.getJWTClaimsSet();
}
何时使用 JWE:
- Token 中必须携带敏感信息(身份证号、手机号等)
- 合规要求(GDPR、等保)
- Token 可能暴露在浏览器 localStorage 中
不推荐 JWE 的场景:
- 高性能要求(加密解密有开销)
- 网关层需要读取 Token 内容做路由判断
- Token 仅用于服务端会话关联(用参考 Token 更合适)
4.7 Refresh Token 轮换策略(补充)
@Service
public class TokenRotationService {
@Autowired
private RedisTemplate<String, String> redis;
// Access Token: 15 分钟
private static final Duration ACCESS_EXPIRY = Duration.ofMinutes(15);
// Refresh Token: 7 天
private static final Duration REFRESH_EXPIRY = Duration.ofDays(7);
// Refresh Token 家族前缀,用于检测重用
private static final String TOKEN_FAMILY = "token:family:";
public TokenPair issueTokens(String userId) {
String familyId = UUID.randomUUID().toString();
String refreshId = UUID.randomUUID().toString();
// 存储 refresh token 家族
redis.opsForValue().set(TOKEN_FAMILY + familyId, refreshId, REFRESH_EXPIRY);
String accessToken = generateAccessToken(userId, familyId);
String refreshToken = generateRefreshToken(userId, familyId, refreshId);
return new TokenPair(accessToken, refreshToken);
}
public TokenPair rotateTokens(String oldRefreshToken) {
Claims claims = validateRefreshToken(oldRefreshToken);
String familyId = claims.get("familyId", String.class);
String oldRefreshId = claims.get("refreshId", String.class);
// 检查 refresh token 是否已被使用(检测重用 → 说明被盗)
String storedRefreshId = redis.opsForValue().get(TOKEN_FAMILY + familyId);
if (storedRefreshId == null || !storedRefreshId.equals(oldRefreshId)) {
// Refresh Token 重用!可能是被盗。
// 立即吊销整个家族
redis.delete(TOKEN_FAMILY + familyId);
redis.opsForValue().set("token:family:revoked:" + familyId, "1", Duration.ofDays(7));
// 强制用户重新登录,并发出告警
log.warn("Refresh Token 重用检测:familyId={}", familyId);
throw new SecurityException("Token 已被使用,请重新登录");
}
// 正常轮换:换发新的 refresh token
String newRefreshId = UUID.randomUUID().toString();
redis.opsForValue().set(TOKEN_FAMILY + familyId, newRefreshId, REFRESH_EXPIRY);
String newAccessToken = generateAccessToken(claims.getSubject(), familyId);
String newRefreshToken = generateRefreshToken(claims.getSubject(), familyId, newRefreshId);
return new TokenPair(newAccessToken, newRefreshToken);
}
public void revokeFamily(String familyId) {
redis.delete(TOKEN_FAMILY + familyId);
}
}
Refresh Token 轮换 vs 短期 Token:
| 方案 | 安全性 | 用户体验 | 复杂度 |
|---|---|---|---|
| 单 Token 长期有效 | ❌ | ✅ | 低 |
| Access Token 短期 + 无 Refresh | ✅ | ❌ 频繁登出 | 低 |
| Access + Refresh 无轮换 | ⚠️ | ✅ | 中 |
| Access + Refresh 轮换 + 重用检测 | ✅ | ✅ | 高 |
4.8 Token 绑定(HTB — Holder-of-Key)(补充)
防止 Token 被盗后在其他设备使用:
// 签发 Token 时绑定客户端指纹
public String issueBoundToken(User user, HttpServletRequest request) {
String fingerprint = generateFingerprint(request);
return Jwts.builder()
.setSubject(user.getId().toString())
.claim("fp_hash", sha256(fingerprint)) // 指纹哈希存入 Token
.setExpiration(new Date(System.currentTimeMillis() + 900_000))
.signWith(privateKey, SignatureAlgorithm.RS256)
.compact();
}
// 验证时比对指纹
public Claims validateBoundToken(String token, HttpServletRequest request) {
Claims claims = validateAndParse(token);
String currentFp = sha256(generateFingerprint(request));
String storedFp = claims.get("fp_hash", String.class);
if (!currentFp.equals(storedFp)) {
throw new SecurityException("Token 客户端指纹不匹配");
}
return claims;
}
// 生成客户端指纹(IP + UA 组合)
private String generateFingerprint(HttpServletRequest request) {
String ip = request.getHeader("X-Forwarded-For");
if (ip == null) ip = request.getRemoteAddr();
String ua = request.getHeader("User-Agent");
return ip + "|" + ua;
}
注意:指纹绑定会降低用户体验(移动网络 IP 频繁变化、UA 升级)。建议仅对高安全场景(银行、支付)启用。
总结
JWT 安全的核心不是选择了哪个库,而是验证逻辑是否正确:
- 算法:永远只接受单一算法(RS256 或 ES256)
- 签名:永远用
parseClaimsJws(带 s),不用parseClaimsJwt - 异常:不能
catch(Exception e)一把梭 - 过期:Access Token ≤ 15 分钟
- 吊销:配合 Refresh Token 轮换 + 重用检测
- 加密:敏感数据用 JWE 加密,或干脆不放 payload 里
系列文章: