【架构实战】JWT认证Token设计与安全边界一、“无状态认证变成无法注销”2021年我们把Session认证升级成了JWT。初衷很简单微服务架构下Session共享太麻烦JWT无状态、不依赖Redis、天然适合分布式。上线后问题接踵而至用户修改密码后旧的JWT仍然有效因为JWT无法主动失效发现一个被盗用的Token但没有任何办法让它立即失效Token体积越来越大payload里塞了太多用户信息请求头超过8KBCTO质问“JWT不是最佳实践吗为什么我们的用户体验反而更差了”答案很简单JWT是无状态的但业务天然有状态。用无状态的方案解决有状态的问题必然要付出代价。二、JWT结构深入解析2.1 JWT三段式结构eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOiIxMjM0NTY3ODkwIn0.dozjgNryP4J3jVmNHl0w5N_XgL0n3I9PlFUP0THsR8U │ │ │ │ Header │ Payload │ Signature │ Base64编码 │ Base64编码 │ HMAC签名 │ │ │ ▼ ▼ ▼ { alg: HS256, { HMACSHA256( typ: JWT sub: 1234567890, base64(header) . } name: 张三, base64(payload), iat: 1516239022, secret exp: 1516240022 ) }2.2 安全性分析// 常见错误使用弱密钥Stringsecret123456;// ❌ 弱密钥可暴力破解// 正确使用足够强度的密钥// HS256: 至少256位随机密钥Stringsecret2f7c8e9a1b3d4f5a6c7d8e9f0a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6e7f8;// 或者使用非对称加密推荐// RS256: 私钥签名公钥验证KeyPairGeneratorkeyGenKeyPairGenerator.getInstance(RSA);keyGen.initialize(2048);KeyPairkeyPairkeyGen.generateKeyPair();// 签名用私钥StringtokenJwts.builder().setSubject(userId).signWith(keyPair.getPrivate(),SignatureAlgorithm.RS256).compact();// 验证用公钥微服务间分发公钥即可ClaimsclaimsJwts.parserBuilder().setSigningKey(keyPair.getPublic()).build().parseClaimsJws(token).getBody();三、JWT的设计实践3.1 Payload设计——小而精// 好的Payload设计精简原则{iss:https://auth.xxx.com,// 签发者sub:user_12345,// 用户唯一标识唯一必须字段aud:order-service,// 受众限制Token使用范围exp:1625097600,// 过期时间15分钟iat:1625096700,// 签发时间nbf:1625096700,// 生效时间jti:unique-token-uuid,// Token唯一IDrole:USER,// 角色尽量精简scope:read:profile// 权限范围}// 错误的Payload设计信息过载{sub:12345,username:zhangsan,email:zhangsanxxx.com,phone:13800138000,avatar:https://cdn.xxx.com/avatar/123.jpg,address:北京朝阳区xxx路xxx号,company:某某科技,department:研发部,position:高级工程师,permissions:[read,write,delete,admin,superadmin],metadata:{...}}// 问题Token体积过大每次请求都要传敏感信息暴露3.2 Token生成器实现ComponentpublicclassJwtTokenProvider{Value(${jwt.access-token-expiry:15})privateintaccessTokenExpiryMinutes;Value(${jwt.refresh-token-expiry:7})privateintrefreshTokenExpiryDays;privatefinalRSAPrivateKeyprivateKey;privatefinalRSAPublicKeypublicKey;privatefinalTokenBlacklistServiceblacklistService;/** * 生成Access Token短期15分钟 */publicStringgenerateAccessToken(UserPrincipaluser){InstantnowInstant.now();returnJwts.builder().setIssuer(https://auth.xxx.com).setSubject(user.getUserId().toString()).setAudience(api.xxx.com).setId(UUID.randomUUID().toString()).claim(role,user.getRole()).claim(scope,String.join( ,user.getPermissions())).setIssuedAt(Date.from(now)).setNotBefore(Date.from(now)).setExpiration(Date.from(now.plus(accessTokenExpiryMinutes,ChronoUnit.MINUTES))).signWith(privateKey,SignatureAlgorithm.RS256).compact();}/** * 生成Refresh Token长期7天可撤销 */publicStringgenerateRefreshToken(UserPrincipaluser){InstantnowInstant.now();StringtokenIdUUID.randomUUID().toString();// 将RefreshToken的ID存入Redis用于撤销验证redisTemplate.opsForValue().set(refresh_token:tokenId,user.getUserId().toString(),refreshTokenExpiryDays,TimeUnit.DAYS);returnJwts.builder().setSubject(user.getUserId().toString()).setId(tokenId).claim(type,refresh).setIssuedAt(Date.from(now)).setExpiration(Date.from(now.plus(refreshTokenExpiryDays,ChronoUnit.DAYS))).signWith(privateKey,SignatureAlgorithm.RS256).compact();}/** * 验证Token并提取用户信息 */publicUserPrincipalvalidateToken(Stringtoken){try{// 1. 验证签名和过期JwsClaimsjwsJwts.parserBuilder().setSigningKey(publicKey).requireIssuer(https://auth.xxx.com).requireAudience(api.xxx.com).build().parseClaimsJws(token);Claimsclaimsjws.getBody();StringjwtIdclaims.getId();// 2. 检查黑名单已注销的Tokenif(blacklistService.isBlacklisted(jwtId)){thrownewJwtException(Token已注销);}// 3. 构建用户主体returnUserPrincipal.builder().userId(Long.parseLong(claims.getSubject())).role(claims.get(role,String.class)).permissions(Arrays.asList(claims.get(scope,String.class).split( ))).build();}catch(JwtExceptione){thrownewAuthenticationException(Token验证失败: e.getMessage());}}}四、JWT的安全边界与补丁4.1 主动注销——Token黑名单ComponentpublicclassTokenBlacklistService{AutowiredprivateRedisTemplateString,StringredisTemplate;/** * 将Token加入黑名单 */publicvoidblacklistToken(StringjwtId,Dateexpiration){longttlexpiration.getTime()-System.currentTimeMillis();if(ttl0){// 存储JWT的唯一ID过期时间跟随原TokenredisTemplate.opsForValue().set(blacklist:jwtId,revoked,ttl,TimeUnit.MILLISECONDS);}}/** * 检查Token是否在黑名单中 */publicbooleanisBlacklisted(StringjwtId){returnBoolean.TRUE.equals(redisTemplate.hasKey(blacklist:jwtId));}}// 用户登出时PostMapping(/logout)publicvoidlogout(RequestHeader(Authorization)StringauthHeader){StringtokenauthHeader.substring(7);ClaimsclaimstokenProvider.parseClaims(token);// 将当前Access Token加入黑名单blacklistService.blacklistToken(claims.getId(),claims.getExpiration());// 删除Refresh TokenredisTemplate.delete(refresh_token:claims.getId());}4.2 Token刷新——旋转机制PostMapping(/refresh)publicTokenResponserefreshToken(RequestBodyRefreshTokenRequestrequest){// 1. 验证Refresh TokenClaimsclaimstokenProvider.parseClaims(request.getRefreshToken());StringtokenIdclaims.getId();// 2. 验证Refresh Token在Redis中存在未被撤销StringuserIdredisTemplate.opsForValue().get(refresh_token:tokenId);if(userIdnull){thrownewAuthenticationException(Refresh Token已失效);}// 3. 删除旧的Refresh Token旋转redisTemplate.delete(refresh_token:tokenId);// 4. 生成新的Token对UserPrincipaluseruserService.loadUserById(Long.parseLong(userId));StringnewAccessTokentokenProvider.generateAccessToken(user);StringnewRefreshTokentokenProvider.generateRefreshToken(user);returnnewTokenResponse(newAccessToken,newRefreshToken);}4.3 存储位置选择存储方式XSS安全CSRF安全适用场景注意事项localStorage不安全天然免疫不推荐XSS可直接读取sessionStorage不安全天然免疫不推荐同localStorageHttpOnly Cookie安全需防护Web应用推荐无法JS读取内存变量安全天然免疫SPA最优刷新页面需重新获取推荐方案Access Token 存内存Refresh Token 存 HttpOnly Secure SameSite Cookie。// Refresh Token Cookie配置ResponseCookierefreshCookieResponseCookie.from(refresh_token,refreshToken).httpOnly(true)// JS无法访问.secure(true)// 仅HTTPS.sameSite(Strict)// 防CSRF.path(/api/auth)// 仅认证接口携带.maxAge(7,ChronoUnit.DAYS).build();response.addHeader(HttpHeaders.SET_COOKIE,refreshCookie.toString());五、微服务中的JWT方案5.1 非对称密钥分发密钥管理方案 ┌─────────────┐ │ 认证服务 │ │ (持有私钥) │ │ 签发JWT │ └──────┬──────┘ │ ┌──────────┼──────────┐ ▼ ▼ ▼ ┌──────────┐ ┌──────────┐ ┌──────────┐ │ 订单服务 │ │ 商品服务 │ │ 用户服务 │ │ (只存公钥) │ │ (只存公钥) │ │ (只存公钥) │ └──────────┘ └──────────┘ └──────────┘ // 好处任何服务都可以验证Token但只有认证服务能签发Token // 微服务间不需要Redis共享Session真正实现了无状态5.2 服务间调用Token传递// 用户请求携带JWT// 服务A → 服务B透传JWT不重新签发ComponentpublicclassFeignClientInterceptorimplementsRequestInterceptor{Overridepublicvoidapply(RequestTemplatetemplate){// 从当前请求上下文获取原始JWTStringtokenRequestContextHolder.currentRequestAttributes().getAttribute(jwt_token,RequestAttributes.SCOPE_REQUEST);if(token!null){// 透传给下游服务template.header(Authorization,Bearer token);}}}六、JWT vs Session决策矩阵维度JWTSession扩展性天然分布式无需共享存储需要Redis等集中存储性能本地验证极快需要查询Redis注销需要黑名单补丁直接删除Session体积携带在请求头有大小限制仅传输SessionId跨域天然支持需要额外配置多设备登录需要额外逻辑天然支持数据权限Token内嵌过期前不变可实时更新七、总结JWT不是银弹它是一种权衡——用无法主动注销换取分布式无状态。理解了这一点才能做出正确的架构决策。核心安全边界Payload保持精简——用户ID 角色 权限不要塞用户资料Access Token 15分钟Refresh Token 7天 旋转机制非对称加密RS256优于对称加密HS256尤其在微服务场景HttpOnly Secure SameSite Cookie 是最安全的Token存储方式Token黑名单是主动注销的代价——用Redis承担这部分状态密钥定期轮换私钥泄露 整个认证体系崩溃选型建议微服务高并发 → JWT 非对称加密传统Web应用 → Session Redis对安全性要求极高 → JWT 短TTL 黑名单一句话JWT的本质是把状态从服务端转移到了Token本身。但业务天然有状态——用户要注销、管理员要封号、密码改了旧Token必须失效。接受这个现实用黑名单补丁兜底才是JWT的正确使用方式。个人观点仅供参考