JWT 看着简单,真做生产级登录态时坑特别多:Access Token 过期怎么续?Refresh Token 怎么轮转?用户登出后怎么真的"失效"那个还没过期的 JWT?多设备登录怎么管?本文把这些问题一次性讲清楚,附 Spring Boot + Redis 完整实现。
JWT 的"无状态"是个 trap
JWT 推销的时候总说"无状态、可扩展、不需要存储"。这是真的,但只对"短期 token"成立。生产里几乎所有需求都打这个前提的脸:
需求 JWT 原生能力 需要外部状态?
=====================================================
登录态过期 exp 字段 不需要
强制登出(token 立即失效) ❌ 做不到 需要 blacklist
管理员封禁用户 ❌ 做不到 需要查 DB
多设备登录列表 ❌ 做不到 需要存 session
强制改密码后旧 token 失效 ❌ 做不到 需要 user_version
结论:生产 JWT 必然带 Redis,严格说不是"无状态认证",是"减少 session 查询的优化"。
Access Token + Refresh Token 双 token 模型
核心代码
@Service
public class JwtService {
@Value("${jwt.secret}") private String secret;
private final long AT_TTL = 15 * 60 * 1000L; // 15 分钟
private final long RT_TTL = 7 * 24 * 3600 * 1000L; // 7 天
@Autowired private StringRedisTemplate redis;
public TokenPair login(Long userId, String deviceId) {
String at = generateAccessToken(userId, deviceId);
String rt = generateRefreshToken(userId, deviceId);
// 存 Refresh Token 到 Redis,key 包含 deviceId,支持多设备
String key = "rt:" + userId + ":" + deviceId;
redis.opsForValue().set(key, rt, RT_TTL, TimeUnit.MILLISECONDS);
return new TokenPair(at, rt);
}
private String generateAccessToken(Long userId, String deviceId) {
long now = System.currentTimeMillis();
return Jwts.builder()
.setSubject(String.valueOf(userId))
.claim("did", deviceId)
.claim("typ", "at")
.claim("ver", getUserVersion(userId)) // 用户版本号,改密码会 +1
.setIssuedAt(new Date(now))
.setExpiration(new Date(now + AT_TTL))
.signWith(SignatureAlgorithm.HS256, secret)
.compact();
}
private String generateRefreshToken(Long userId, String deviceId) {
// RT 用 UUID,不签 JWT,方便 revoke
return UUID.randomUUID().toString().replace("-", "");
}
public TokenPair refresh(String oldRT, String deviceId) {
// 通过 RT 反查 userId(需要在 Redis 用 hash 存反向索引)
String userIdStr = redis.opsForValue().get("rt_to_uid:" + oldRT);
if (userIdStr == null) throw new InvalidTokenException("RT not found or expired");
Long userId = Long.parseLong(userIdStr);
// 校验 RT 是这个设备的
String storedRT = redis.opsForValue().get("rt:" + userId + ":" + deviceId);
if (!oldRT.equals(storedRT)) {
// RT 不一致:要么被偷了在异地用,要么是过期的旧 RT
// 安全策略:撤销该设备的所有 RT,强制重新登录
revokeDevice(userId, deviceId);
throw new InvalidTokenException("RT mismatch");
}
// 生成新 AT + 新 RT(轮转)
String newAT = generateAccessToken(userId, deviceId);
String newRT = generateRefreshToken(userId, deviceId);
redis.opsForValue().set("rt:" + userId + ":" + deviceId, newRT, RT_TTL, TimeUnit.MILLISECONDS);
redis.opsForValue().set("rt_to_uid:" + newRT, String.valueOf(userId), RT_TTL, TimeUnit.MILLISECONDS);
redis.delete("rt_to_uid:" + oldRT);
return new TokenPair(newAT, newRT);
}
}
JWT 校验拦截器
@Component
public class JwtFilter extends OncePerRequestFilter {
@Autowired private StringRedisTemplate redis;
@Autowired private UserService userService;
@Override
protected void doFilterInternal(HttpServletRequest req, HttpServletResponse resp, FilterChain chain)
throws ServletException, IOException {
String auth = req.getHeader("Authorization");
if (auth == null || !auth.startsWith("Bearer ")) {
chain.doFilter(req, resp);
return;
}
String token = auth.substring(7);
try {
Claims claims = Jwts.parser().setSigningKey(secret).parseClaimsJws(token).getBody();
Long userId = Long.parseLong(claims.getSubject());
String deviceId = claims.get("did", String.class);
Integer tokenVer = claims.get("ver", Integer.class);
// 校验 1: 黑名单(强制登出)
if (Boolean.TRUE.equals(redis.hasKey("blacklist:" + token))) {
throw new InvalidTokenException("token revoked");
}
// 校验 2: 用户版本号(改密码 / 账号封禁后 +1)
Integer currentVer = userService.getUserVersion(userId);
if (!Objects.equals(tokenVer, currentVer)) {
throw new InvalidTokenException("token version stale");
}
// 注入 SecurityContext
UsernamePasswordAuthenticationToken authToken =
new UsernamePasswordAuthenticationToken(userId, null, List.of());
SecurityContextHolder.getContext().setAuthentication(authToken);
} catch (ExpiredJwtException e) {
resp.setStatus(401);
resp.getWriter().write("{\"error\":\"token_expired\"}");
return;
} catch (Exception e) {
resp.setStatus(401);
resp.getWriter().write("{\"error\":\"invalid_token\"}");
return;
}
chain.doFilter(req, resp);
}
}
强制登出(立即失效)
@Service
public class LogoutService {
@Autowired private StringRedisTemplate redis;
public void logout(String accessToken, Long userId, String deviceId) {
// 把 AT 加入黑名单,有效期=AT 剩余时间
Claims claims = Jwts.parser().setSigningKey(secret).parseClaimsJws(accessToken).getBody();
long remainMs = claims.getExpiration().getTime() - System.currentTimeMillis();
if (remainMs > 0) {
redis.opsForValue().set("blacklist:" + accessToken, "1", remainMs, TimeUnit.MILLISECONDS);
}
// 撤销该设备的 RT
String rtKey = "rt:" + userId + ":" + deviceId;
String rt = redis.opsForValue().get(rtKey);
if (rt != null) {
redis.delete("rt_to_uid:" + rt);
redis.delete(rtKey);
}
}
public void logoutAllDevices(Long userId) {
// 把用户版本号 +1,所有 AT 失效
userService.incrementUserVersion(userId);
// 撤销所有设备的 RT
Set<String> keys = redis.keys("rt:" + userId + ":*");
if (keys != null) keys.forEach(redis::delete);
}
}
RT 轮转 + 重放检测
用户的 RT 应该每次 refresh 都换(rotation)。如果用旧 RT 来 refresh,说明这个 RT 可能被人窃取了同时被两边用:
public TokenPair refreshWithReuseDetection(String oldRT, String deviceId) {
String key = "rt:" + userId + ":" + deviceId;
String currentRT = redis.opsForValue().get(key);
if (currentRT == null) {
throw new InvalidTokenException("RT expired");
}
if (!oldRT.equals(currentRT)) {
// RT 用过的 / 被替换的 → 极有可能是攻击者拿了快照
// 立刻撤销所有设备(安全派),或者只撤销该设备(便利派)
revokeAllDevices(userId);
log.error("RT REUSE DETECTED uid={} deviceId={}", userId, deviceId);
throw new SecurityException("RT reuse detected, all sessions revoked");
}
// ... 正常轮转 ...
}
Web 端 token 存哪里(安全)
存储位置 优点 缺点
========================================================
localStorage 简单 XSS 可读,不推荐
sessionStorage 页面关闭就丢 XSS 可读
内存(JS 变量) 刷新就丢,需 RT 续 最安全 + 体验最好,推荐
httpOnly cookie XSS 读不到 CSRF 风险,需要 SameSite
secureFlag cookie 强制 HTTPS 同上
// 推荐方案:AT 在内存,RT 在 httpOnly cookie
// 后端 /login 返回:
res.cookie('rt', refreshToken, {
httpOnly: true,
secure: true, // 只在 HTTPS 下传
sameSite: 'strict', // 防 CSRF
maxAge: 7 * 24 * 3600 * 1000
});
res.json({ access_token: at }); // AT 在 body 里,前端存内存
// 前端 axios 拦截器:401 自动 refresh
axios.interceptors.response.use(null, async error => {
if (error.response?.status === 401 && !error.config._retried) {
error.config._retried = true;
const { data } = await axios.post('/auth/refresh'); // cookie 自动带 RT
store.setAccessToken(data.access_token);
error.config.headers.Authorization = `Bearer ${data.access_token}`;
return axios(error.config); // 重放原请求
}
return Promise.reject(error);
});
常见反模式
反模式 1:把敏感信息塞 JWT payload
JWT 只是 base64 编码,不是加密。手机号 / 邮箱 / 角色都能被前端解析。
✅ 只放 userId,其他字段去 DB / Redis 拉
反模式 2:AT 设很长(比如 30 天)
AT 一旦泄露,30 天内都能用。
✅ AT 短(15min - 1h),RT 长(7-30 天)+ 可撤销
反模式 3:用 RSA / ES256 但还是签发后只对称校验
性能差还没意义。微服务网关签发用 RSA 私钥,后端服务用公钥校验才合理
✅ 单体应用 HS256 够,微服务网关用 RS256 / ES256
反模式 4:登出只删 cookie 不拉黑 token
token 还是有效的,只是浏览器不带了。攻击者抓包之前的 token 还能用。
✅ 必须服务端拉黑
反模式 5:RT 不轮转
一个 RT 用 7 天,被偷了攻击者用一周
✅ 每次 refresh 都换新 RT
多设备管理 + "我的设备"列表
@Service
public class DeviceService {
@Autowired private StringRedisTemplate redis;
public void recordDevice(Long userId, String deviceId, DeviceInfo info) {
String key = "user_devices:" + userId;
// hash 结构: deviceId -> info_json
redis.opsForHash().put(key, deviceId, JSON.toJSONString(info));
redis.expire(key, 30, TimeUnit.DAYS);
}
public List<DeviceInfo> listDevices(Long userId) {
Map<Object, Object> all = redis.opsForHash().entries("user_devices:" + userId);
return all.entrySet().stream()
.map(e -> {
DeviceInfo d = JSON.parseObject(e.getValue().toString(), DeviceInfo.class);
d.setDeviceId(e.getKey().toString());
d.setActive(redis.hasKey("rt:" + userId + ":" + d.getDeviceId()));
return d;
})
.collect(Collectors.toList());
}
public void revokeDevice(Long userId, String deviceId) {
String rtKey = "rt:" + userId + ":" + deviceId;
redis.delete(rtKey);
redis.opsForHash().delete("user_devices:" + userId, deviceId);
}
}
性能注意点
操作 耗时 备注
=========================================
JWT 签名 HS256 0.05ms 纯 CPU 操作,无 IO
JWT 验签 HS256 0.05ms 同上
Redis 黑名单查询 0.2ms 每个请求一次
Redis 用户版本查询 0.2ms 可以本地缓存 5s 减少
DB 查用户角色 2-10ms 建议缓存
优化:用户版本号在 JVM 内缓存 5 秒,降低 Redis QPS 50%
JWT 选型总结
- 登录态必须双 token,AT 短 RT 长
- RT 必须轮转 + 复用检测
- 必须支持服务端撤销(黑名单 + user_version 双保险)
- 敏感信息不进 payload
- Web 端 RT 用 httpOnly + SameSite,AT 在内存
- App 端 RT 用 keychain / keystore,AT 在内存
- 多设备管理用 deviceId 维度的 RT
- 所有 token 操作要有审计日志
这套方案我们维护了 3 年多,期间出过 token 泄露事件被复用检测捕获,出过用户改密码后还能用旧 token 的 bug(就是没做 user_version),都通过这个体系迭代解决。JWT 是个看起来简单实际很深的话题,值得花一周认真做扎实。
—— 别看了 · 2026