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 中使用 HttpOnly cookie 而不是 localStorage。
  • 为 refresh token 提供撤销/黑名单机制。

13. 总结

JWT 本身没有缺陷——大多数问题源于实现方式不当:密钥太弱、信任客户端发送的 alg、token 生命周期过长,或在公开的 payload 中存放敏感数据。按照第 12 节的检查清单操作,并始终使用经过验证的 JWT 库(jjwt、nimbus-jose-jwt)而不是自己编写解析器,你将避免大多数常见的安全漏洞。下一步值得研究的方向是 OAuth 2.0 / OpenID Connect,以便在多服务规模上更好地管理 token 生命周期。

jwtweb-securityspring-bootauthenticationjava