先说一个场景:用户正在后台填写一个长时间的表单,或者正在编辑一篇很长的内容,突然页面跳转到登录页,回来一看之前填的东西全没了。大多数情况下,不是系统有bug,而是token过期了。把token有效期调长,比如设置成24小时甚至7天,确实能缓解,但代价是安全风险指数级上升。token一旦泄露,攻击者拿到的就是一个长期有效的通行证。
这个矛盾在Java后端尤其常见。Spring Boot + Spring Security + JWT这套组合几乎成了国内后端的默认配置,但很多团队对token过期处理得非常粗糙。我的做法是让token在用户活跃期间自动续期,用户不活跃才真正过期,既保证了体验,又不会无限拉长token的有效期。这个方案不是那种花哨的黑科技,而是一套可以落地的工程实践,接下来我把完整的思路和代码拆开来讲。
1. 登录态过期这个“小问题”为什么值得专门做续期
先搞清楚一个核心问题:token为什么要有过期时间?没有过期时间的token就相当于一把永远不用还的钥匙,用户登录一次,服务器就再也不管他是否还在活跃。
JWT本身是无状态的,服务端不知道这个token什么时候该失效,唯一能依赖的就是token里自带的exp字段。一旦签发,在到达过期时间之前,只要签名正确,服务端就得认。这带来一个尴尬:token过期时间设短了(比如30分钟),用户体验差,用着用着就被踢下线;设长了(比如24小时甚至7天),安全风险明显上升,token被截获后能在很长时间内畅通无阻。
用“到期强制重登”这种粗暴方案的项目我见过很多,运营反馈最多的就是“用户经常掉线”。用户被踢出去之后还要重新输入账号密码,如果忘了密码还要走一遍找回流程,转化率在登录这一步就流失了不少。这种体验上的损失是隐性的,但业务方感受非常明显。
自动续期的核心思路概括起来就一句话:用户活跃的时候,token不要到期;用户一段时间不活跃,token才优雅过期。这句话听起来简单,落地时衍生出了几种不同方案,有的在JWT本身做文章,有的借助Redis实现滑动过期,有的用双token机制把“认证”和“续期”彻底分开。
在动手之前,必须先想清楚三个边界条件:
- 什么算“活跃”?正常来说,用户调用了任意需要登录态的接口,都算活跃。
- 续期的触发点是什么?是请求打进来时判断,还是后台定时任务扫描?
- 续期后,旧的token是立即失效,还是继续可用,还是等它自然过期?
这三个问题如果不提前定清楚,后面写代码很容易陷入纠结。下面我直接给出我认为最合理的做法,再解释为什么。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 最直接的思路:拦截器检测 + Redis滑动续期
先讲第一种方案,也是我对很多内部项目推荐的首选方案:利用Redis的过期时间实现滑动续期,JWT本身不续期、不刷新,只负责承载用户身份信息。
2.1 方案的整体思路
用户登录成功后,服务端做两件事:
- 签发一个正常的JWT,
exp设置成比如30分钟,这个token由客户端保存,每次请求放在Authorization头里。 - 同时在Redis里写入一个key,key的格式如
login:token:{userId},value存token字符串,过期时间也是30分钟。
以后每次请求进来,除了校验JWT的签名和exp,还要额外校验Redis中是否存在这个token对应的key。如果存在,就用Redis的EXPIRE命令重置key的过期时间,继续30分钟。如果不存在,说明用户已经长时间没有活跃,即使JWT本身还没到期,服务端也强制要求重新登录。
这里最关键的就是那一步:重置Redis过期时间。Redis中key的过期时间一旦被重新设置,就相当于以当前时间点重新计时,所以用户只要一直有操作,Redis里的key就一直不过期,形同一个“滑动窗口”。
2.2 为什么方案要放在Redis而不是JWT本身
JWT设计上是无状态的,签发之后服务端不能主动改它。所以如果只靠JWT,要么在exp上做文章(但改不了),要么重新签一个新token返回给客户端,让客户端自己换。而Redis天然支持“动态过期”,一行EXPIRE命令就实现了续期逻辑,不需要重新签发token,也不需要客户端参与。
Redis还有另一个好处:它可以做到服务端主动失效。比如用户修改密码、强制下线、封号,直接删掉Redis里的key,对应的token就立刻失效了,这在纯JWT方案里很难做到——JWT签发出去之后,服务端没法主动让它失效,只能等exp到期。
2.3 代码怎么写:从登录到拦截器
先看登录时怎么签发token并写入Redis。下面是一段基于Spring Boot + jjwt的实现:
java复制@Service
public class TokenService {
@Autowired
private StringRedisTemplate stringRedisTemplate;
private static final Duration TOKEN_EXPIRE = Duration.ofMinutes(30);
private static final String LOGIN_TOKEN_KEY = "login:token:";
public String login(String userId) {
// 生成JWT,有效期30分钟
String jwt = Jwts.builder()
.setSubject(userId)
.setIssuedAt(new Date())
.setExpiration(new Date(System.currentTimeMillis() + TOKEN_EXPIRE.toMillis()))
.signWith(SignatureAlgorithm.HS256, secretKey)
.compact();
// 写入Redis,过期时间同样设置30分钟
stringRedisTemplate.opsForValue().set(
LOGIN_TOKEN_KEY + userId,
jwt,
TOKEN_EXPIRE
);
return jwt;
}
}
这里有个细节:我把JWT的exp和Redis的过期时间设置成了相同的值。很多人会问,既然Redis过期时间已经能控制失效了,JWT的exp是不是可以设得很长?我建议不要,两个时间保持一致,逻辑更清晰。JWT30分钟到期之后,签名校验就会失败,Redis里的key留着也没有意义,它也会在30分钟后自动被清理。
接着写一个拦截器,在请求进入Controller之前完成校验和续期:
java复制@Component
public class TokenInterceptor implements HandlerInterceptor {
@Autowired
private StringRedisTemplate stringRedisTemplate;
@Override
public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler)
throws Exception {
String authHeader = request.getHeader("Authorization");
if (authHeader == null || !authHeader.startsWith("Bearer ")) {
throw new UnauthorizedException("未登录");
}
String jwt = authHeader.substring(7);
String userId;
try {
Claims claims = Jwts.parser()
.setSigningKey(secretKey)
.parseClaimsJws(jwt)
.getBody();
userId = claims.getSubject();
} catch (JwtException e) {
throw new UnauthorizedException("token无效或已过期");
}
String redisKey = "login:token:" + userId;
String storedToken = stringRedisTemplate.opsForValue().get(redisKey);
if (storedToken == null || !storedToken.equals(jwt)) {
throw new UnauthorizedException("登录已过期,请重新登录");
}
// 核心:重置Redis过期时间,实现滑动续期
stringRedisTemplate.expire(redisKey, Duration.ofMinutes(30));
// 把用户信息放入请求上下文
request.setAttribute("userId", userId);
return true;
}
}
每次请求进来,校验通过之后顺手把Redis的key过期时间重置,用户只要还在操作,登录态就永远不会掉。如果30分钟内没有任何请求,Redis key先到期,JWT的exp也会在同一时间点到期,双重保险,客户端下次请求时拦截器就会抛“登录已过期”,重定向到登录页。
2.4 这个方案的优势与不足
优势非常明显:
- 逻辑简单,改动量小,一个拦截器 + 一个Redis key就能搞定。
- 不需要客户端配合,token不会变,客户端体验无感。
- 服务端可以随时把某个用户踢下线,直接删Redis key即可。
- 刷新操作只涉及一次Redis命令,性能开销很小。
不足的地方也有:
- 服务端需要记录每个登录用户的key,和“纯JWT无状态”的设计初衷背离。这个问题在单机或中小规模集群下完全可接受,但如果你真的是无状态至上的架构,得斟酌一下。
- 高并发下,每次请求都要做一次Redis读写。这个不是大问题,Redis扛这个量很轻松,但如果是极端复杂的微服务链路,建议在网关层统一处理,避免每个服务重复做续期。
3. 更稳的方案:access token + refresh token 双令牌
如果你觉得“每次请求都重置Redis过期时间”还不够优雅,或者你的系统对安全性要求更高,双token机制是业界更成熟的方案。这也是很多大厂在用的做法。
3.1 双token为什么能解决单token的痛点
前面那种滑动续期方案其实是“一个token走天下”,它的特点是token在不断“续命”,但同一个token的有效期在逻辑上被无限拉长了。这里有个安全隐患:token一旦在客户端被截获,攻击者只要一直在用,这个token就不会失效,服务端完全没有办法区分当前请求是真实用户还是攻击者。
双token方案把“认证凭证”和“续期凭证”拆开:
- access token:短期有效,比如30分钟,用于每次请求的认证,被盗了影响面可控。
- refresh token:长期有效,比如7天或14天,只用于一个接口,就是“换新access token”的接口。它不会随请求到处发送,所以暴露面小很多。
access token过期后,客户端拿着refresh token去调用刷新接口,服务端校验refresh token有效,签发一个新的access token返回。用户在不知不觉中就完成了token的更换,不需要重新输入密码。
refresh token在刷新之后要不要轮换(rotation),是另一个工程决策。轮换的意思是,每次刷新成功后,刷新接口返回一个新的refresh token,旧的refresh token立即失效。这样即使refresh token被截获,也只能用一次,安全性进一步提升。
3.2 核心代码实现
以Spring Boot为例,登录时同时签发两个token:
java复制public class TokenPair {
private String accessToken;
private String refreshToken;
// getter/setter省略
}
@Service
public class TokenService {
public TokenPair generateTokenPair(String userId) {
String accessToken = Jwts.builder()
.setSubject(userId)
.setExpiration(new Date(System.currentTimeMillis() + 15 * 60 * 1000))
.signWith(SignatureAlgorithm.HS256, accessKey)
.compact();
String refreshToken = Jwts.builder()
.setSubject(userId)
.setExpiration(new Date(System.currentTimeMillis() + 7 * 24 * 60 * 60 * 1000))
.signWith(SignatureAlgorithm.HS256, refreshKey)
.compact();
// 把refreshToken存入Redis,value记录使用的次数,这里先假设1次
stringRedisTemplate.opsForValue().set(
"refresh:" + userId, refreshToken, Duration.ofDays(7)
);
return new TokenPair(accessToken, refreshToken);
}
}
注意一个细节:access token和refresh token最好用不同的密钥签名,这样两个token的校验逻辑可以完全分开,不会混用。
刷新接口的代码:
java复制@RestController
@RequestMapping("/auth")
public class AuthController {
@PostMapping("/refresh")
public TokenPair refresh(@RequestBody RefreshRequest request) {
String oldRefreshToken = request.getRefreshToken();
// 校验refresh token签名
Claims claims;
try {
claims = Jwts.parser()
.setSigningKey(refreshKey)
.parseClaimsJws(oldRefreshToken)
.getBody();
} catch (JwtException e) {
throw new UnauthorizedException("refresh token无效");
}
String userId = claims.getSubject();
// 校验Redis中存储的refresh token是否一致
String storedToken = stringRedisTemplate.opsForValue().get("refresh:" + userId);
if (!oldRefreshToken.equals(storedToken)) {
throw new UnauthorizedException("refresh token已失效,请重新登录");
}
// 删除旧的refresh token,发放新的令牌对(rotation)
stringRedisTemplate.delete("refresh:" + userId);
return tokenService.generateTokenPair(userId);
}
}
到这里,双token的机制就完整了。access token有效期短,即使被截获,15分钟内就作废;refresh token虽然有效期长,但它只出现在登录和刷新这两个接口的请求里,并且每次刷新都会轮换,旧refresh token立即失效。攻击者想通过截获一个refresh token来长期伪造身份,难度大了很多。
3.3 双token方案在前后端交互中的配合
光有后端还不够,前端也要配合好。当后端返回401时,前端不应该直接跳登录页,而是先尝试用refresh token换一个新的access token,换成功就把请求重新发一遍,换失败才跳登录页。
axios的拦截器是个典型的实现方式:
javascript复制service.interceptors.response.use(
response => response,
async error => {
const { response } = error;
if (response && response.status === 401) {
const refreshToken = localStorage.getItem('refreshToken');
if (refreshToken) {
try {
const { data } = await axios.post('/auth/refresh', { refreshToken });
localStorage.setItem('accessToken', data.accessToken);
localStorage.setItem('refreshToken', data.refreshToken);
// 重放原始请求
return service(error.config);
} catch (refreshError) {
// 刷新失败,跳登录页
}
}
}
return Promise.reject(error);
}
);
这个模式的好处是用户完全无感。access token过期了,前端自动换新token并重发请求,用户甚至不知道发生过token刷新。
3.4 双token方案的优缺点对比
优点方面:
- access token有效期短,被盗后影响面可控。
- refresh token不随请求传输,暴露面小。
- 可以实现refresh token轮换,防止重放攻击。
- 用户体验好,自动续期,不会出现操作被打断的情况。
缺点:
- 实现复杂度高,前后端都要配合。
- 刷新逻辑如果处理不好,容易出现并发刷新问题(后面会讲)。
- refresh token本身也是token,它的有效期设计需要仔细权衡。
4. 微服务场景:网关层统一续期
如果你的系统是微服务架构,我有一个强烈建议:token的校验和续期不要在每个业务服务里重复写,放到网关层统一处理。这看起来像是“多一层转发”的额外开销,实际上省掉了大量重复代码和无休止的关键逻辑不一致问题。
4.1 为什么业务服务里不适合做续期
微服务里,一个用户请求可能要经过用户服务、订单服务、库存服务等多个服务。如果每个服务都做一次“校验token + 续期”,会出现两个问题:
- 同一个用户请求被多次刷新过期时间,虽然不会出错,但确实做了大量无用功。
- 不同服务的校验逻辑容易出现版本不一致。有的服务升级了代码,有的没升级,就会出现“这个接口要重新登录,那个接口还正常”的诡异现象。
正确的做法是网关层统一校验,业务服务信任网关转发过来的用户身份信息。
4.2 Spring Cloud Gateway里怎么实现
下面给一个基于Spring Cloud Gateway的全局Filter示例,代码不复杂,但点出了续期在微服务场景下的正确姿势:
java复制@Component
public class TokenAuthFilter implements GlobalFilter, Ordered {
@Override
public Mono<Void> filter(ServerWebExchange exchange, GatewayFilterChain chain) {
String token = resolveToken(exchange.getRequest());
// 校验逻辑:解析JWT、校验签名、检查Redis
// 校验通过后,把userId写入header,转发给下游服务
ServerHttpRequest mutatedRequest = exchange.getRequest().mutate()
.header("X-User-Id", userId)
.build();
// 续期:重置Redis过期时间
stringRedisTemplate.expire("login:token:" + userId, Duration.ofMinutes(30));
return chain.filter(exchange.mutate().request(mutatedRequest).build());
}
@Override
public int getOrder() {
return -100;
}
}
网关层做完校验和续期之后,下游服务不需要关心token怎么来的、怎么校验的,直接从header里取userId就行。这样做还有一个额外的好处:如果想切换续期策略,比如从“30分钟滑动”改成“2小时滑动”,只需要改网关一个地方,所有服务立即生效。
4.3 网关层续期的一个踩坑点
在网关层做续期很容易忽略一个问题:静态资源和匿名接口的放行。如果一个请求不需要登录,比如登录接口本身、验证码接口、公开的商品列表接口,网关就不应该去校验证书,更不应该续期。
我见过的一个真实事故是这样:某个团队在网关层统一接了token校验,把登录接口也拦了,结果用户登录时发的请求里根本没有token,直接被网关拒掉,整个系统没办法登录。排查了半天才发现是网关过滤器的白名单没加登录接口。所以实现网关Filter时,一定要先维护白名单,白名单内的路径直接放行。
5. 上线后踩过的三个坑
方案落地不难,难的是上线之后的各种边界情况。这里把我实际踩过、也帮别人排查过的三个坑列出来,每一个都值得认真看。
5.1 并发请求下“refresh token轮换”把自己人踢下线
双token方案里,如果前端同时发起了多个请求,而access token恰好在这个时间点过期,会出现什么情况?多个请求同时收到401,每个请求都拿到同一个refresh token去刷新,第一个请求刷新成功,旧的refresh token作废;后面的请求再用同一个refresh token刷新,就会因为“token已失效”而失败。
这种问题不解决,用户会看到有些请求成功、有些请求失败的诡异状况。处理思路有两个:
-
前端做并发刷新控制:用一个全局的
isRefreshing标志,同一个时刻只允许一个刷新请求真正发出去,其他401请求进入等待队列,刷新完成后统一重放。这是成熟的做法,代码稍微多一点,但体验最好。 -
后端在刷新接口里放宽校验:短时间窗口内允许同一个refresh token使用多次,每次刷新都返回新的refresh token。这样做的风险是刷新接口可能被重放,但配合refresh token的“使用次数追踪”可以控制风险。
我个人的建议是两件事都做:前端做并发控制,后端做短时间窗口容忍。因为前端不可能百分之百保证所有客户端都不出并发问题。
5.2 续期之后Redis key自动过期时间不一致
有一个细节容易被忽略:登录时写入Redis的过期时间是30分钟,拦截器里重置过期时间也是30分钟,看起来一致,但如果在某次请求中,JWT的exp只剩下1分钟了,这时即使Redis的key被重置了30分钟,JWT也会在1分钟后因为签名过期而校验失败,用户还是会被踢下线。
所以在滑动续期方案里,JWT的exp不能比Redis的过期时间短。更稳妥的做法是:不依赖JWT的exp来判断失效,而是把JWT的exp设置得足够长(比如24小时),真正的失效判断完全依赖Redis。因为Redis的key存在,token就有效;Redis key没了,token立即失效。JWT的exp在这里只是一个兜底,防止Redis数据意外丢失导致token永不过期。
5.3 时间戳问题:服务器时钟偏移导致token提前过期
JWT的exp是基于系统时间计算的。如果服务器的系统时间不准确,或者集群中不同节点的时钟不一致,就可能出现“客户端token明明还有效,但某个节点认为已过期”的情况。
Java应用可以用System.currentTimeMillis()取当前时间,它受服务器系统时钟影响。解决方法是运维上保证NTP时间同步,应用层面尽量不要自己封装时间函数。如果系统对时间精度要求高,可以把时间基准放到数据库或Redis统一获取,但大多数业务场景下NTP同步就足够了。
6. 关于“优雅”这件事的几点个人体会
回到标题里说的“优雅”。其实这套方案严格来说是业界常规打法,组合了Redis的滑动过期、双token、网关统一处理这些成熟技术,真正的价值在于把它们合理地组合在一起。
我在实际主导这些方案的过程中,认识到一点:优雅不是用最炫的技术,而是让用户无感知,让代码容易维护,让系统在异常情况下也不会伤害用户体验。
一些对你有用的参考意见:
做续期方案前先想清楚,你的业务到底需要哪种强度的方案。内部管理系统,纯后台工具,用拦截器+Redis就够了;面向C端用户的系统,建议直接上双token;微服务系统,把校验统一放到网关。
Token有效期没有统一标准,我经手的项目中,access token有15分钟的,有30分钟的,也有2小时的。原则是:安全要求越高,时间越短;用户操作越频繁,时间越长。通常30分钟是一个平衡点,既不会过于频繁触发刷新,也不会在token泄露时造成太大的暴露窗口。
不要试图一次性把所有逻辑写满。先把核心链路跑通——用户登录、请求校验、过期续期、踢人下线,再逐步补上并发控制、日志记录、异常告警这些外围能力。核心链路做扎实了,这套方案才算真的落地了。
最后我想说,token自动续期这件事,代码量不大,但它连接着安全、用户体验、系统架构三个层面,值得花时间认真设计。希望我上面分享的这些经验和踩过的坑,能帮你在做Java后端登录态管理时少走一些弯路。
