JWT 签完改不了权限:无状态登录的续签方案
背景

我们项目最近做了从 Session 到 JWT 的迁移。原来的系统用 Spring Session 把用户信息存 Redis,网关层校验 token,逻辑简单清晰。改成 JWT 之后,服务确实轻松了不少——每个节点都能独立验签,不需要 Redis 参与鉴权链路,水平扩展时也不用担心 Session 漂移。
但上线一周后,运维同学反馈了一个问题:管理员在后台撤销了某个用户的权限,用户却能继续操作。查了一下原因,JWT 是签名后自包含的,没有服务端存储,所以已签发的 token 不受任何控制——要么等到过期,要么主动把它从黑名单里排除掉。
这个问题引出了无状态登录的真正矛盾:无状态的代价是什么,以及如何在保留无状态优势的同时解决它。
原理:JWT 的构成与验证
JWT 由三部分组成,用点分隔:header.payload.signature。
header 声明算法,payload 存放声明(claims),signature 用私钥对前两部分签名。服务端验签时只用公钥,不需要查询数据库或缓存。
// payload 示例
{
"sub": "user_12345",
"role": "admin",
"iat": 1698764400,
"exp": 1698768000
}
exp 字段控制有效期,验签时检查时间戳是否过期。这就是 JWT 的核心——所有信息都在 token 里,服务端只负责验证签名,不维护任何状态。
这个设计带来的好处是明显的:无依赖、可横向扩展、网关层就能完成鉴权。但同样的设计也造成了权限变更延迟的问题——token 不失效,用户的权限就不会变。
利弊:无状态登录适合什么场景
选择 JWT 之前,需要明确它解决的问题和它不能解决的问题。
适合的场景:
- 微服务架构,服务之间不需要共享会话状态
- 需要跨域访问,token 放在请求头而不是 cookie
- 客户端是移动端或 SPA,服务端不需要感知在线状态
- 吞吐量高、低延迟要求的接口
不适合的场景:
- 需要立即生效的权限变更(如审计合规要求)
- 用户需要"主动登出",让 token 立即失效
- 会话状态需要携带大量数据(JWT payload 越大,传输开销越大)
- 对安全性要求极高,不能容忍任何 token 泄露后的攻击窗口
我们的项目大部分场景适合 JWT,但权限撤销这个需求触发了无状态登录的短板。解决这个问题的思路有两个方向:黑名单和双 Token。
方案一:黑名单——最直接的兜底
黑名单的思路很简单:把需要失效的 token 存到 Redis 里,验签时先查黑名单。
// Spring Security 过滤器示例
public class JwtAuthFilter extends OncePerRequestFilter {
@Override
protected void doFilterInternal(HttpServletRequest request,
HttpServletResponse response,
FilterChain chain)
throws ServletException, IOException {
String token = extractToken(request);
if (token != null) {
String jti = getJtiFromToken(token);
// 检查黑名单
if (redisTemplate.hasKey("jwt:blacklist:" + jti)) {
throw new AuthenticationException("Token已失效");
}
// 正常验签逻辑
Claims claims = parseClaims(token);
SecurityContextHolder.getContext()
.setAuthentication(new JwtAuthentication(claims));
}
chain.doFilter(request, response);
}
}
缺点也很明显:每次鉴权都要查一次 Redis,引入了外部依赖。这和我们选择 JWT 的初衷——消除鉴权链路中的缓存依赖——相悖。如果 Redis 挂了,整个鉴权就瘫痪了。
黑名单更适合做补充手段,而不是主方案。
方案二:双 Token——兼顾无状态与可控性
双 Token 是目前比较流行的方案:.access token 短有效期负责鉴权,.refresh token 长有效期负责续签。
登录成功 → 返回 access_token(15min) + refresh_token(7d)
每次请求 → 用 access_token 鉴权
access_token 过期 → 用 refresh_token 申请新 token
refresh_token 过期 → 重新登录
关键设计点有三个:
1. refresh token 必须服务端存储
access token 可以完全无状态,但 refresh token 需要记录在 Redis 里,这样才能主动失效。存储时建议用 uid:device_id 作为 key,支持单设备或多设备管理。
# Redis 存储结构
# Key: refresh_token:{rt_id}
# Value: {uid, device_id, created_at, expires_at}
# TTL: 7天
jwt:
access:
expiration: 900s # 15分钟
refresh:
expiration: 604800s # 7天
2. 续签接口的幂等与安全
续签接口容易被滥用,需要限制频率和来源。建议:
- 同一 refresh token 只能使用一次,续签后旧 token 失效
- 新增 token 时使用新的 jti(JWT ID),旧 jti 立即加入内存黑名单
- 记录签发日志,方便异常追溯
@PostMapping("/auth/refresh")
public ResponseEntity<?> refreshToken(
@RequestHeader("Authorization") String authHeader) {
String oldRefreshToken = extractToken(authHeader);
RefreshTokenEntity entity = validateRefreshToken(oldRefreshToken);
// 生成新 pair
String newAccessToken = jwtService.generateAccessToken(entity.getUid());
String newRefreshToken = jwtService.generateRefreshToken(entity.getUid());
// 旧 refresh token 失效
invalidateRefreshToken(oldRefreshToken);
// 旧 access token 加入内存黑名单
addToMemoryBlacklist(oldAccessTokenJti);
return ResponseEntity.ok(new TokenResponse(newAccessToken, newRefreshToken));
}
3. 短 access token 降低风险窗口
15 分钟的 access token 意味着即使 token 泄露,攻击者也只有 15 分钟的操作窗口。配合续签机制,用户体验几乎不受影响——大部分请求都是正常的 token 鉴权,只有快到期的那一瞬间才需要续签。
问题排查:常见坑与应对
坑一:续签接口被刷
有次发现某个账号的 refresh token 在短时间内被请求了几百次。排查后发现是爬虫在探测续签接口。解决方式:
- 续签接口加验证码或设备指纹校验
- 同一账号每分钟最多续签 N 次
- 监控续签失败率,异常时告警
坑二:多设备登录冲突
用户在手机和电脑同时登录,手机登出后电脑还能用。这是 refresh token 粒度设计的问题。如果按 uid 存储,登出会清除所有设备;如果按 uid:device_id 存储,登出只影响当前设备。建议默认按设备隔离,管理后台提供"强制下线所有设备"选项。
坑三:时间同步问题
JWT 的 exp 校验依赖服务端时钟。如果集群中各节点时间不一致,可能出现 token 提前失效或延迟失效。解决方式:
- 集群内 NTP 同步
- 校验 exp 时允许 ±30 秒的偏差
坑四:黑名单膨胀
如果用 Redis 存黑名单,过期 token 不断积累,Redis 内存会持续增长。解决方式:
- 黑名单设置 TTL,到期自动删除
- 定期清理过期条目
- 内存黑名单+定期持久化,避免每次鉴权都查 Redis
选型建议
回到最初的问题:要不要用 JWT?
如果你的系统满足以下任一条件,JWT 是合适的:
- 服务无状态化,不想维护会话
- 需要跨域、移动端支持
- 鉴权链路不希望依赖 Redis
如果以下任一条件更重要,考虑保留 Session:
- 权限变更必须立即生效(如金融、医疗系统)
- 需要精确控制会话生命周期
- 团队对 Redis 依赖不敏感
双 Token 方案可以在大多数场景下兼顾两者:access token 保持无状态,refresh token 提供必要的可控性。权限撤销时,只需要让用户的 refresh token 失效,下次续签时就会触发重新认证,效果等同于立即下线。
最终,无状态不是目的,可维护性才是。JWT 解决了会话共享的问题,但引入了权限控制的复杂度。理解这个权衡,才能在合适的场景做出合适的选择。

浙公网安备 33010602011771号