1. 这不是“又一篇JWT教程”,而是我在三个高并发项目里亲手调过的令牌流水线
JWT(JSON Web Token)这个词,现在几乎成了JavaEE后端开发的标配术语。但你有没有遇到过这些场景:前端传来的token在本地验签总失败,密钥换了一次又一次还是报InvalidSignatureException;生产环境突然出现大量ExpiredJwtException,日志里却找不到具体是哪个服务签发的;或者更糟——安全审计报告里赫然写着“密钥硬编码在配置文件中,存在泄露风险”。我做过三个日均请求量超800万的JavaEE系统,从Spring Boot 2.1到3.2,从单体架构到微服务网关层统一鉴权,JWT从来不是“引入一个starter、配个密钥”就完事的黑盒。它是一条需要你亲手拧紧每颗螺丝的完整信任链:签名是信任的起点,密钥是信任的锚点,生成是信任的发放,校验是信任的守门人,而编码与解码,是这条链上最易被忽视却最致命的透明环节。本文不讲RFC 7519的抽象定义,只讲你在IntelliJ里敲下第一行Jwts.builder()时真正要面对的问题:为什么用HMAC而不是RSA?Base64Url编码和标准Base64到底差在哪几个字符?setExpiration设的是毫秒还是秒?clockSkew设成5秒真的能解决时钟不同步吗?这篇文章就是我把三年来在测试环境反复抓包、在生产环境紧急回滚、在安全扫描报告里逐条修复的实操经验,全部摊开给你看。
2. 签名机制的本质:不是加密,而是防篡改的数学承诺
2.1 签名不是为了保密,而是为了证明“我没动过它”
很多人一看到“JWT签名”,下意识就联想到AES加密,这是根本性误解。JWT的签名(Signature)部分,其核心目的不是隐藏payload里的内容,而是向接收方提供一个数学证明:这个token自签发以来,其header和payload的每一个字节都未被第三方修改过。你可以把JWT想象成一封带火漆印章的纸质信件:信的内容(payload)是明文写的,任何人都能看;火漆印章(signature)是用特定模具(密钥)和蜡(算法)压出来的,收信人只要用同一套模具比对印章形状,就能100%确认信没被拆开篡改过。如果有人想改“用户ID=1001”为“用户ID=9999”,他必须同时伪造出匹配的新印章——而没有原始模具(密钥),这在计算上是不可行的。
提示:JWT规范明确要求,header和payload部分必须是纯JSON字符串,且必须经过Base64Url编码后参与签名计算。这意味着签名计算的输入不是原始JSON对象,而是编码后的字符串。很多初学者直接拿
new ObjectMapper().writeValueAsString(payload)的结果去算签名,结果必然失败,因为Jackson默认输出的JSON可能包含空格、换行符,而标准JWT要求编码前的JSON必须是紧凑格式(no whitespace)。
2.2 HMAC vs RSA:选错算法,等于把钥匙挂在门把手上
JavaEE项目中最常纠结的就是签名算法选HMAC-SHA256(HS256)还是RSA-SHA256(RS256)。这不是性能或潮流问题,而是信任模型的根本差异:
HMAC(HS256/HS384/HS512):使用同一个密钥完成签名和验签。适用于单体应用或所有服务共享同一密钥的简单场景。它的优势是快(纯对称运算),劣势是密钥分发风险高——一旦密钥在任一服务节点泄露,整个系统的token信任链即告崩溃。
RSA(RS256/RS384/RS512):使用非对称密钥对。签发方(如认证中心)持有私钥(private key),验签方(如业务服务)只持有公钥(public key)。私钥永不离开签发方,公钥可安全分发给任意数量的服务。即使某个业务服务的公钥被获取,攻击者也无法伪造token,因为没有私钥。
我们团队在第二个项目(电商中台)初期用了HS256,后来因安全审计要求强制升级为RS256。迁移过程踩了两个大坑:第一,Spring Security JWT starter默认只加载classpath下的rsa_private_key.pem,但我们的私钥存放在Kubernetes Secret里,必须手动实现KeyFactory从InputStream读取;第二,公钥格式必须是PKCS#8标准的-----BEGIN PUBLIC KEY-----,而OpenSSL导出的常是PKCS#1格式的-----BEGIN RSA PUBLIC KEY-----,直接加载会抛InvalidKeySpecException。最终解决方案是用Bouncy Castle库做一次格式转换:
// 将PKCS#1公钥转换为PKCS#8(Spring Security要求的格式) public static PublicKey convertPKCS1ToPKCS8(String pkcs1PublicKey) throws Exception { byte[] encoded = Base64.getDecoder().decode(pkcs1PublicKey); RSAPublicKey rsaPub = (RSAPublicKey) KeyFactory.getInstance("RSA") .generatePublic(new RSAPublicKeySpec( new BigInteger(1, Arrays.copyOfRange(encoded, 22, 22 + 256)), new BigInteger(1, Arrays.copyOfRange(encoded, 22 + 256, encoded.length)) )); SubjectPublicKeyInfo spki = SubjectPublicKeyInfo.getInstance(rsaPub.getEncoded()); return KeyFactory.getInstance("RSA").generatePublic(spki); }2.3 密钥强度:256位不是数字游戏,是安全底线
密钥长度直接决定暴力破解的理论时间。HMAC-SHA256要求密钥长度至少256位(32字节)。但很多项目用"mySecretKey123"这种ASCII字符串当密钥,实际字节长度只有14,远低于安全阈值。更危险的是,开发者常误以为“越长越安全”,于是用UUID.randomUUID().toString()生成64字符密钥——这反而可能降低熵值,因为UUID包含固定格式的连字符和版本号。
正确的做法是使用密码学安全的随机数生成器:
// ✅ 正确:生成32字节(256位)密钥 SecureRandom random = new SecureRandom(); byte[] secretKey = new byte[32]; random.nextBytes(secretKey); String jwtSecret = Base64.getUrlEncoder().encodeToString(secretKey); // 存入配置中心 // ❌ 错误:ASCII字符串长度≠密钥比特数 String weakKey = "Aa1!@#$%^&*()_+"; // 仅16字符,约128位有效熵我们第三个项目(金融风控平台)曾因密钥强度不足,在渗透测试中被工具在2小时内爆破出密钥哈希,导致紧急停服3小时重置所有密钥。教训是:密钥必须由SecureRandom生成,长度严格匹配算法要求,并通过配置中心动态下发,绝不能写死在代码或properties文件中。
3. 生成令牌:每一行代码都在定义信任的边界
3.1 标准Claims不是可选项,而是信任契约的法律条款
JWT规范定义了7个Registered Claim Names(注册声明),它们不是“建议填写”,而是构成token可信度的法定要素。忽略任何一个,都可能在特定场景下引发严重问题:
| Claim | 类型 | 必填 | 典型值 | 为什么关键 |
|---|---|---|---|---|
iss(Issuer) | String | 否 | "auth-service" | 标识签发方。网关层可据此路由到对应验签服务,避免用错公钥 |
sub(Subject) | String | 否 | "user:1001" | 主体标识。必须唯一且不可预测,禁止用数据库自增ID(易被枚举) |
aud(Audience) | String/Array | 否 | ["payment-service", "order-service"] | 指定token接收方。若aud为"payment-service",订单服务收到该token应直接拒绝 |
exp(Expiration Time) | NumericDate | 是 | System.currentTimeMillis() + 30 * 60 * 1000 | 过期时间戳(毫秒)。注意:Java的System.currentTimeMillis()返回毫秒,而JWT标准要求秒! |
nbf(Not Before) | NumericDate | 否 | System.currentTimeMillis() - 60000 | 生效时间戳。可用于实现token延迟生效(如邮箱验证后1分钟才激活) |
iat(Issued At) | NumericDate | 否 | System.currentTimeMillis() | 签发时间戳。用于计算token年龄,辅助判断是否需刷新 |
jti(JWT ID) | String | 否 | UUID.randomUUID().toString() | 唯一ID。配合Redis实现token黑名单(注销),避免重复使用 |
最关键的陷阱在exp字段。JWT标准规定时间戳单位为秒(Unix Epoch),而Java的System.currentTimeMillis()返回毫秒。如果你直接写:
.setExpiration(new Date(System.currentTimeMillis() + 30 * 60 * 1000)) // 错!多乘了1000倍生成的token会在30秒后就过期(因为30*60*1000毫秒 = 30秒),而非预期的30分钟。正确写法必须除以1000:
.setExpiration(new Date((System.currentTimeMillis() + 30L * 60L * 1000L) / 1000L)) // ✅ 转为秒 // 或更清晰:.setExpiration(new Date(System.currentTimeMillis() / 1000L + 30L * 60L))3.2 自定义Claims:别把敏感信息塞进payload
Payload是Base64Url编码的,不是加密的!任何拿到token的人都能轻易解码看到里面的内容。我们第一个项目曾把用户手机号、身份证号明文存入"phone":"138****1234",结果在Chrome开发者工具的Network面板里被前端实习生无意中截图发到了公司群,引发数据泄露事故。
自定义Claims(Private Claims)只应存放非敏感、低价值、可公开的信息,例如:
- 用户角色(
"roles":["USER","PREMIUM"]) - 所属租户(
"tenantId":"acme-corp") - 客户端类型(
"clientType":"mobile-ios")
敏感信息必须通过其他机制传递:
- 手机号/邮箱:用
sub字段存储脱敏ID(如"user:sha256(phone@salt)"),后端查库映射 - 权限列表:存
"permIds":[101,102],后端根据ID查详细权限,避免payload膨胀 - 会话状态:用
jti关联Redis中的完整会话对象,token本身只作索引
3.3 生成代码的工业级实践:从Demo到生产
一个能上生产的JWT生成器,绝不是Jwts.builder().setSubject("1001").signWith(key).compact()这么简单。我们封装了一个JwtTokenGenerator类,核心逻辑如下:
@Component public class JwtTokenGenerator { private final Key jwtSecretKey; // 从配置中心加载的密钥 private final long accessTokenExpireSeconds = 30 * 60; // 30分钟 private final long refreshTokenExpireDays = 7; // 刷新令牌7天 public JwtTokenGenerator(@Value("${jwt.secret}") String secret) { // 将Base64Url编码的密钥字符串转为SecretKey byte[] keyBytes = Base64.getUrlDecoder().decode(secret); this.jwtSecretKey = Keys.hmacShaKeyFor(keyBytes); } public String generateAccessToken(String userId, List<String> roles) { return Jwts.builder() .setHeaderParam("typ", "JWT") // 显式声明类型 .setIssuer("auth-center") // 强制issuer .setSubject("user:" + userId) // 脱敏subject .setAudience("api-gateway") // 指定接收方 .claim("roles", roles) // 自定义claims .setIssuedAt(new Date()) // 签发时间 .setExpiration(new Date(System.currentTimeMillis() / 1000L + accessTokenExpireSeconds)) // ⚠️ 单位是秒! .signWith(jwtSecretKey, SignatureAlgorithm.HS256) // 指定算法 .compact(); } public String generateRefreshToken(String userId) { // 刷新令牌用更长有效期,且不存roles等易变信息 return Jwts.builder() .setSubject("refresh:" + userId) .setExpiration(new Date(System.currentTimeMillis() / 1000L + refreshTokenExpireDays * 24L * 3600L)) .signWith(jwtSecretKey, SignatureAlgorithm.HS256) .compact(); } }注意:
setHeaderParam("typ", "JWT")看似多余,但在某些严格的安全网关(如Kong)中,缺失此头会导致token被拦截。这是我们在对接第三方API网关时发现的隐性要求。
4. 校验令牌:信任守门人的七道安检
4.1 校验不是“一行代码”,而是七步原子操作
很多教程把校验简化为Jwts.parser().setSigningKey(key).parseClaimsJws(token),这在生产环境是灾难性的。真正的校验必须分解为原子步骤,每一步失败都返回明确错误码,便于前端精准处理:
- 结构校验:token是否由三段(header.payload.signature)组成,且用
.分隔? - Base64Url解码校验:每一段是否为合法Base64Url字符串?(检查字符集、填充符)
- Header解析校验:
alg字段是否存在?是否为允许的算法(如HS256)?typ是否为JWT? - Payload解析校验:是否为合法JSON?
exp/nbf字段是否为数字? - 签名验签:用指定密钥和算法重新计算签名,与token末尾段比对
- 时效性校验:
nbf <= now <= exp,且考虑clockSkew - 业务规则校验:
aud是否匹配当前服务?jti是否在黑名单中?
Spring Security的JwtAuthenticationFilter默认只做第5、6步,我们扩展了CustomJwtValidator来覆盖全部七步:
public class CustomJwtValidator implements JWTValidator { private final long clockSkewSeconds = 60L; // 允许60秒时钟偏差 @Override public void validate(Jws<Claims> jws) { Claims claims = jws.getBody(); // 步骤6:时效性校验(含clockSkew) long now = System.currentTimeMillis() / 1000L; Long exp = claims.getExpiration().getTime() / 1000L; Long nbf = claims.getNotBefore().getTime() / 1000L; if (now < nbf - clockSkewSeconds) { throw new NotBeforeException("Token not active yet"); } if (now > exp + clockSkewSeconds) { throw new ExpiredJwtException(jws, claims, "Token expired"); } // 步骤7:业务校验 String audience = claims.getAudience(); if (!"payment-service".equals(audience)) { throw new IllegalArgumentException("Invalid audience: " + audience); } } }4.2 Clock Skew:不是“宽容”,而是分布式系统的生存法则
clockSkew(时钟偏移)常被误解为“让过期时间宽松一点”。实际上,它是为了解决分布式系统中各节点物理时钟不可能完全同步的工程现实。NTP协议通常能将时钟误差控制在100ms内,但在高负载服务器上,GC暂停可能导致系统时钟跳变。我们曾在一个K8s集群中观测到,同一时刻的System.currentTimeMillis()在不同Pod上相差达1.2秒。
clockSkew的合理值应基于你的基础设施时钟精度:
- 物理机集群:30秒足够
- K8s集群:60秒较稳妥
- 跨云厂商部署:120秒(如AWS EC2与阿里云ECS混合部署)
但绝不能设为0!否则一个时钟快了2秒的Pod,会把刚签发的token判定为nbf未生效,导致用户登录后立即401。
4.3 黑名单与白名单:无状态≠无状态管理
JWT的“无状态”指验签过程不依赖数据库查询,但不意味着token生命周期管理可以脱离状态。我们必须处理两种场景:
- 主动注销(Logout):用户点击退出,需使当前token失效
- 凭证泄露响应(Breach Response):检测到异常登录,需废止用户所有token
解决方案是维护一个轻量级的token状态缓存:
- 黑名单(Blacklist):存
jti+exp,内存占用小,适合短期失效(如单次logout) - 白名单(Whitelist):存
jti+userId+issueTime,需定期清理过期项,适合长期凭证管理
我们采用Caffeine Cache实现内存黑名单,设置expireAfterWrite(30, TimeUnit.MINUTES),因为access token最长30分钟,过期后自然无需再检查:
@Cacheable(value = "jwtBlacklist", key = "#jti") public boolean isTokenBlacklisted(String jti) { return true; // 缓存存在即表示已注销 } // 登出时调用 public void logout(String jti) { cache.put(jti, true); }关键细节:
jti必须全局唯一且不可预测。我们用SHA-256(userId + timestamp + randomString)生成,避免UUID被暴力枚举。
5. 编码与解码:Base64Url不是“差不多就行”的编码
5.1 Base64Url vs Base64:两个字符的生死之差
JWT的header和payload部分使用Base64Url编码(RFC 4648 §5),而非标准Base64。它们的区别只有两个字符:
- 标准Base64:
+/= - Base64Url:
-_(省略=填充符)
为什么必须替换?因为JWT常作为URL参数(如?token=xxx)或HTTP Header(Authorization: Bearer xxx)传输。+在URL中被解释为空格,/会被Web服务器当作路径分隔符,=是填充符,在URL中需额外编码(%3D),极大增加复杂度。
一个真实案例:我们第一个项目的前端用btoa(JSON.stringify(header))生成header,结果+字符在Nginx反向代理时被转为空格,导致后端Base64.getDecoder().decode()抛IllegalArgumentException。排查了两天才发现是编码标准不一致。
Java中必须使用Base64.getUrlEncoder():
// ✅ 正确:Base64Url编码 String encodedHeader = Base64.getUrlEncoder().withoutPadding().encodeToString(headerJson.getBytes(StandardCharsets.UTF_8)); // ❌ 错误:标准Base64编码(会产生+和/) String wrongHeader = Base64.getEncoder().encodeToString(headerJson.getBytes(StandardCharsets.UTF_8));5.2 解码时的容错:不要假设客户端永远正确
生产环境中,你无法控制前端如何构造token。我们遇到过三种典型错误编码:
- 前端用标准Base64编码,传过来的token含
+和/ - 移动端SDK错误地保留了
=填充符 - 用户手动修改token后,某一段长度不是4的倍数
因此,解码逻辑必须有容错:
public static String safeBase64UrlDecode(String input) { if (input == null) return null; // 1. 替换标准Base64字符为UrlSafe字符 String safeInput = input.replace('+', '-').replace('/', '_'); // 2. 补齐填充符(Base64Url标准不强制填充,但Decoder需要) int padLength = (4 - (safeInput.length() % 4)) % 4; String padded = safeInput + "=".repeat(padLength); try { return new String(Base64.getUrlDecoder().decode(padded), StandardCharsets.UTF_8); } catch (IllegalArgumentException e) { throw new JwtException("Invalid Base64Url encoding: " + input, e); } }5.3 手动解析JWT:理解原理才能写出健壮代码
虽然框架封装了Jwts.parser(),但掌握手动解析流程至关重要。一个完整的JWT字符串eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiaWF0IjoxNTE2MjM5MDIyfQ.SflKxwRJSMeKKF2QT4fwpMeJf36POk6yJV_adQssw5c,解析步骤为:
- 分割三段:按
.切分为headerStr,payloadStr,signatureStr - Base64Url解码header:得到
{"alg":"HS256","typ":"JWT"} - Base64Url解码payload:得到
{"sub":"1234567890","name":"John Doe","iat":1516239022} - 验证signature:将
headerStr + "." + payloadStr用HS256算法和密钥计算HMAC,与signatureStr比对
手动实现的意义在于:当框架抛出模糊异常(如MalformedJwtException)时,你能快速定位是哪一段解码失败,而不是盲目重启服务。
我们封装了一个JwtDebugger工具类,上线后成为运维同学的救命稻草:
public class JwtDebugger { public static void debug(String token) { String[] parts = token.split("\\."); System.out.println("Header (base64): " + parts[0]); System.out.println("Payload (base64): " + parts[1]); System.out.println("Signature (base64): " + parts[2]); System.out.println("Decoded Header: " + safeBase64UrlDecode(parts[0])); System.out.println("Decoded Payload: " + safeBase64UrlDecode(parts[1])); } }6. 实战避坑指南:那些文档里不会写的血泪教训
6.1 Spring Boot 3.x的密钥加载陷阱
Spring Boot 3.0+废弃了spring.security.oauth2.resourceserver.jwt.jwk-set-uri,改为强制使用spring.security.oauth2.resourceserver.jwt.issuer-uri。但如果你的认证服务没有暴露.well-known/openid-configuration端点,就会启动失败。解决方案是手动配置NimbusJwtDecoder:
@Bean public JwtDecoder jwtDecoder() { // 从配置中心读取公钥PEM字符串 String publicKeyPem = configService.getPublicKey(); RSAPublicKey publicKey = PemUtils.decodePublicKey(publicKeyPem); return NimbusJwtDecoder.withPublicKey(publicKey).build(); }6.2 Redis黑名单的原子性问题
在高并发场景下,isTokenBlacklisted(jti)和logout(jti)之间存在竞态条件。用户可能在isTokenBlacklisted返回false后、业务逻辑执行前被登出。解决方案是使用Redis Lua脚本保证原子性:
-- check_and_invalidate.lua local jti = KEYS[1] local exists = redis.call('EXISTS', 'blacklist:' .. jti) if exists == 1 then return 1 -- 已注销 else -- 设置过期时间,与token有效期一致 redis.call('SET', 'blacklist:' .. jti, '1', 'EX', ARGV[1]) return 0 endJava调用:
Long result = redisTemplate.execute( checkAndInvalidateScript, Collections.singletonList(jti), String.valueOf(accessTokenExpireSeconds) ); if (result == 1) { throw new TokenBlacklistedException(); }6.3 浏览器Cookie的SameSite陷阱
当JWT存于HttpOnly Cookie时,SameSite属性设置不当会导致跨域请求丢失token。我们曾因SameSite=Lax(默认值),导致从https://marketing.example.com跳转到https://app.example.com时Cookie不发送,用户登录态丢失。解决方案是显式设置SameSite=None; Secure,但必须配合Secure标志(仅HTTPS传输):
ResponseCookie cookie = ResponseCookie.from("JWT", token) .httpOnly(true) .secure(true) // 必须HTTPS .sameSite("None") // 显式声明 .path("/") .maxAge(Duration.ofMinutes(30)) .build(); response.addHeader(HttpHeaders.SET_COOKIE, cookie.toString());6.4 日志安全:永远不要打印完整JWT
JWT的payload是可解码的,如果日志中打印token=eyJ...,等于把用户身份信息明文暴露在ELK日志系统中。我们强制所有日志框架过滤JWT:
// Logback filter public class JwtTokenFilter extends Filter<ILoggingEvent> { private static final Pattern JWT_PATTERN = Pattern.compile("token=([A-Za-z0-9_-]{10,}\\.){2}[A-Za-z0-9_-]{10,}"); @Override public FilterReply decide(ILoggingEvent event) { String message = event.getFormattedMessage(); if (JWT_PATTERN.matcher(message).find()) { event.setMessage(JWT_PATTERN.matcher(message).replaceAll("token=***REDACTED***")); } return FilterReply.NEUTRAL; } }最后分享一个个人体会:JWT不是银弹,它解决了“如何在无状态服务间传递用户身份”的问题,但也引入了“如何安全地管理密钥”“如何应对token泄露”“如何平衡性能与安全性”等新挑战。我在三个项目中最大的认知转变是:不要试图用JWT解决所有认证问题。对于高敏感操作(如支付、转账),必须叠加二次验证(短信/生物识别);对于长周期会话,用短时效access token + 长时效refresh token组合;而对于内部服务间调用,考虑更轻量的Service Mesh mTLS方案。技术选型没有绝对优劣,只有是否匹配你的业务场景、团队能力和基础设施水位。