JWT:正确使用与常见安全错误
1. 总体概览
JWT(JSON Web Token)被广泛用于 API 身份验证,但很多系统的实现方式不当,导致了严重的安全漏洞。本文总结了在 Spring Boot 中正确创建、验证 JWT 的方法,包括经典的安全错误(alg confusion、弱密钥、不检查 expiration 等)及其防护方案。读完本文,你将了解如何安全地配置 JWT,并更有信心地审查同事的 JWT 代码。
2. JWT 的结构与工作机制
JWT 由 3 部分组成:header.payload.signature,使用 base64url 编码,用点号分隔。
eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOiJhbGljZSJ9.4Z... (signature)
- Header:包含
alg(签名算法)和typ。 - Payload:包含声明,如
sub、exp、iat、roles。 - Signature:用于验证 token 未被篡改的签名。
注意: JWT 不加密 payload,只进行编码。任何拥有 token 的人都可以读取内容。不要将密码、PII 等敏感数据放入声明中。
3. 添加依赖
<dependency>
<groupId>io.jsonwebtoken</groupId>
<artifactId>jjwt-api</artifactId>
<version>0.12.6</version>
</dependency>
<dependency>
<groupId>io.jsonwebtoken</groupId>
<artifactId>jjwt-impl</artifactId>
<version>0.12.6</version>
<scope>runtime</scope>
</dependency>
<dependency>
<groupId>io.jsonwebtoken</groupId>
<artifactId>jjwt-jackson</artifactId>
<version>0.12.6</version>
<scope>runtime</scope>
</dependency>
Java 中最常用的 jjwt(io.jsonwebtoken)库,API 清晰,并且默认已经阻止了一些常见错误。
4. 正确地创建和验证 token
4.1. 生成足够强的密钥
import io.jsonwebtoken.security.Keys;
import javax.crypto.SecretKey;
public class JwtKeyFactory {
public static SecretKey generateKey() {
return Keys.secretKeyFor(io.jsonwebtoken.SignatureAlgorithm.HS256);
}
}
Keys.secretKeyFor 会生成适合 HS256 的随机密钥(≥ 256 位)。在生产环境中,应该将密钥存储在密钥管理服务(如 Vault、AWS Secrets Manager),不要在代码中硬编码。
4.2. 使用 TokenService 创建 token
import io.jsonwebtoken.Jwts;
import javax.crypto.SecretKey;
import java.time.Instant;
import java.util.Date;
public class TokenService {
private final SecretKey key;
public TokenService(SecretKey key) {
this.key = key;
}
public String generateToken(String username) {
Instant now = Instant.now();
return Jwts.builder()
.subject(username)
.issuedAt(Date.from(now))
.expiration(Date.from(now.plusSeconds(900))) // 15 分钟
.signWith(key)
.compact();
}
}
expiration设置为 15 分钟——access token 应该生命周期较短。signWith(key)会根据密钥类型自动选择合适的算法,防止客户端干预alg。
4.3. 验证 token
import io.jsonwebtoken.Claims;
import io.jsonwebtoken.JwtException;
import io.jsonwebtoken.Jwts;
import javax.crypto.SecretKey;
public class TokenVerifier {
private final SecretKey key;
public TokenVerifier(SecretKey key) {
this.key = key;
}
public Claims verify(String token) {
try {
return Jwts.parser()
.verifyWith(key)
.build()
.parseSignedClaims(token)
.getPayload();
} catch (JwtException e) {
throw new InvalidTokenException("token 无效或已过期", e);
}
}
}
parseSignedClaims 会自动检查签名和 exp。如果签名错误或 token 过期,它会抛出 JwtException——你应该捕获并转换为自己的领域异常。
5. 安全错误 #1:alg=none 或 alg 混淆
攻击者可以修改 header 为 {"alg":"none"},然后发送一个没有签名的 token。如果后端不强制要求特定的算法,它可能会接受伪造的 token。
// 错误:没有指定允许的算法
Jwts.parser().build().parseSignedClaims(token); // 旧版本库容易被骗
防护方案——始终白名单化算法:
import io.jsonwebtoken.Jwts;
Jwts.parser()
.verifyWith(key) // 只接受已知的密钥
.build()
.parseSignedClaims(token); // jjwt 0.12+ 已自动阻止 alg=none
注意: 对于其他 JWT 库(jose4j、nimbus-jose-jwt),要明确配置白名单算法(例如只允许
HS256)。永远不要相信由客户端发送的 token 中的alg字段。
一个更危险的变体是:HS256 和 RS256 之间的 alg 混淆。如果服务器用 RSA 公钥验证,但代码在 alg=HS256 时将其作为 HMAC 的密钥,攻击者可以用公钥(这是公开的)自签 token。解决方案是根据使用场景固定算法,不要让代码根据 token 中的 alg 字段来选择验证方法。
6. 安全错误 #2:密钥过弱或密钥泄露
// 错误:密钥太短,容易被暴力破解
String secret = "mysecret123";
SecretKey key = Keys.hmacShaKeyFor(secret.getBytes());
HS256 要求密钥 ≥ 256 位(32 字节)。短密钥可以在几小时内使用 hashcat 等工具离线暴力破解。
// 正确:密钥足够长且随机
SecretKey key = Keys.secretKeyFor(SignatureAlgorithm.HS256);
此外:
- 不要将密钥提交到 Git。使用环境变量或密钥管理器。
- 定期轮换密钥,特别是在有权访问密钥的人员离职时。
# application.properties - 从环境变量读取,不要硬编码
jwt.secret=${JWT_SECRET}
7. 安全错误 #3:不检查过期时间或 token 生命周期过长
// 错误:access token 生存期 30 天太长
.expiration(Date.from(now.plus(30, ChronoUnit.DAYS)))
access token 的生命周期过长意味着如果被泄露,攻击者长时间内都有访问权限,而你无法撤销(JWT 是无状态的,默认没有登出机制)。
正确的模式:access token 短期(5-15 分钟)+ refresh token 长期,在服务器存储(或轮换),这样才能撤销。
public class TokenPair {
private final String accessToken; // 生存期 15 分钟
private final String refreshToken; // 生存期 7 天,存储在 DB 中以便撤销
// 构造函数、getter
}
public class RefreshTokenRepository {
public boolean isRevoked(String refreshTokenId) {
// 查询 DB/Redis,如果 token 已被登出则返回 true
return false;
}
}
当 refresh token 被撤销时,旧的 access token 仍会生存到过期为止——这就是 access token 必须较短的原因。
8. 安全错误 #4:在 payload 中放置敏感数据
// 错误:payload 是公开的,任何拥有 token 的人都可以读取
.claim("password", user.getPassword())
.claim("creditCard", user.getCardNumber())
Payload 只是 base64 编码,并未加密。用 jwt.io 解码就能看到全部内容。应该只放必要的授权信息:sub、roles、exp、scope。
// 正确
.subject(user.getUsername())
.claim("roles", user.getRoles())
9. 安全错误 #5:多个服务共享密钥时不验证 issuer/audience
如果多个服务使用相同的密钥来签发 token,为服务 A 发行的 token 如果不检查 aud(audience),可能被重放到服务 B。
import io.jsonwebtoken.Jwts;
Claims claims = Jwts.parser()
.verifyWith(key)
.requireAudience("order-service") // 只接受为该服务发行的 token
.requireIssuer("auth-server")
.build()
.parseSignedClaims(token)
.getPayload();
requireAudience 和 requireIssuer 强制 parser 在声明不匹配时抛出异常——防止 token 被误用。
10. 与 Spring Security filter 集成
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.web.filter.OncePerRequestFilter;
public class JwtAuthFilter extends OncePerRequestFilter {
private final TokenVerifier verifier;
public JwtAuthFilter(TokenVerifier verifier) {
this.verifier = verifier;
}
@Override
protected void doFilterInternal(HttpServletRequest request,
HttpServletResponse response,
FilterChain chain) throws IOException, ServletException {
String header = request.getHeader("Authorization");
if (header != null && header.startsWith("Bearer ")) {
try {
var claims = verifier.verify(header.substring(7));
var auth = new UsernamePasswordAuthenticationToken(
claims.getSubject(), null, List.of());
SecurityContextHolder.getContext().setAuthentication(auth);
} catch (InvalidTokenException e) {
SecurityContextHolder.clearContext();
}
}
chain.doFilter(request, response);
}
}
- 从
Authorization: Bearer <token>header 提取 token。 - 使用第 4.3 节编写的
TokenVerifier进行验证。 - 如果 token 无效,清除上下文——请求将被 Spring Security 在后续授权步骤中阻止,不在 filter 中直接抛异常。
注意: 如果前端是公开的 SPA,不要将 JWT 存储在
localStorage——容易被 XSS 攻击窃取。优先使用HttpOnly、Secure、SameSite=Strict的 cookie。
11. 测试 token 验证行为
import io.jsonwebtoken.security.Keys;
import org.junit.jupiter.api.Test;
import javax.crypto.SecretKey;
import java.time.Instant;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
import static org.assertj.core.api.Assertions.assertThat;
class TokenVerifierTest {
private final SecretKey key = Keys.secretKeyFor(io.jsonwebtoken.SignatureAlgorithm.HS256);
private final TokenService tokenService = new TokenService(key);
private final TokenVerifier verifier = new TokenVerifier(key);
@Test
void shouldVerifyValidToken() {
String token = tokenService.generateToken("alice");
var claims = verifier.verify(token);
assertThat(claims.getSubject()).isEqualTo("alice");
}
@Test
void shouldRejectTokenSignedWithDifferentKey() {
SecretKey otherKey = Keys.secretKeyFor(io.jsonwebtoken.SignatureAlgorithm.HS256);
TokenService fakeService = new TokenService(otherKey);
String forgedToken = fakeService.generateToken("attacker");
assertThatThrownBy(() -> verifier.verify(forgedToken))
.isInstanceOf(InvalidTokenException.class);
}
}
第二个测试准确模拟了攻击者用不同密钥自签 token 的场景——verifier 必须拒绝它,不能默认接受。
shouldVerifyValidToken() PASS
shouldRejectTokenSignedWithDifferentKey() PASS
12. 部署前快速检查清单
- 密钥 ≥ 256 位,存储在密钥管理器中,不要硬编码。
- 白名单化算法,不相信 token 中的
alg。 - access token 生命周期短(≤ 15 分钟),有 refresh token 用于撤销。
- 不要在 payload 中放置敏感数据。
- 在需要时验证
exp、iss、aud。 - 在前端 SPA 中使用
HttpOnlycookie 而不是localStorage。 - 为 refresh token 提供撤销/黑名单机制。
13. 总结
JWT 本身没有缺陷——大多数问题源于实现方式不当:密钥太弱、信任客户端发送的 alg、token 生命周期过长,或在公开的 payload 中存放敏感数据。按照第 12 节的检查清单操作,并始终使用经过验证的 JWT 库(jjwt、nimbus-jose-jwt)而不是自己编写解析器,你将避免大多数常见的安全漏洞。下一步值得研究的方向是 OAuth 2.0 / OpenID Connect,以便在多服务规模上更好地管理 token 生命周期。