固定过期时间是绝对的,用户的使用是连续的。一个活跃用户不该因为时钟走到某个刻度而被踢出。
问题
最常见的 JWT Session 管理方式:签发一个 Token,设个固定过期时间,到期重新登录。
看似合理,但用户体验上有个尴尬场景——用户连续一周每天都来访问,第六天晚上还在操作,第二天早上回来,Session 过期了,被迫重新登录。
明明昨天还在用,凭什么今天就要重新认证?
本质矛盾:过期时间是硬性的,而人的行为是连续的。 一个正在活跃使用的 Session 不应该被时钟强制终止。
目标
一条朴素的规则:
七天内有过访问的,保持登录;超过七天没来的,自然过期。
不是"七天硬过期",而是"七天滑动窗口"——每一次访问都把窗口往后推。
方案对比
| 方案 | 做法 | 优劣 |
|---|---|---|
| 长过期 + 不续签 | JWT 直接设 30d | 简单粗暴,但一旦签发就无法收回,安全性差 |
| 双 Token(Access + Refresh) | 短期 Access + 长期 Refresh,客户端主动换 Token | 安全性好,但复杂度陡增——需要 Refresh Token 存储、额外 API 端点、客户端换 Token 逻辑 |
| 滑动续期 | 服务端在请求链路上检测剩余有效期,临近过期时静默续签 | 简单,无额外存储,天然实现滚动窗口,对客户端完全透明 |
滑动续期适合中小规模站点——实现成本低、行为符合直觉、7 天窗口本身就限制了风险周期。
核心概念
滑动续期的逻辑只有一句话:
当 JWT 剩余有效期不足总时长的一半时,静默签发新 Token 写回 Cookie。
为什么不等到最后几小时?
- 续期和过期在时间上太近,用户断网或关浏览器后下次回来可能刚好越过那条线
- 一半是常见实践(值可以调),逻辑不变:越早续,越不容易意外过期
时间线示意
假设 Token 总有效期 7 天,续期阈值 3.5 天:
Day 0 登录,签发 JWT(exp = Day 7)
Day 1 访问,剩余 6d > 3.5d → 不续期
Day 2 访问,剩余 5d > 3.5d → 不续期
Day 3 访问,剩余 4d > 3.5d → 不续期
Day 4 访问,剩余 3d < 3.5d → ✅ 续期,新 exp = Day 11
Day 5 访问,剩余 6d > 3.5d → 不续期
...
Day 10 访问,剩余 1d < 3.5d → ✅ 续期,新 exp = Day 17
如果 Day 4 之后再也不访问:
Day 11 → JWT 过期 → 下次访问需要重新登录
效果:活跃用户的 Session 永不过期;不活跃用户自然失效。
实现架构
滑动续期不需要客户端做任何改动——续期发生在服务端的请求拦截层(Middleware / Filter / Interceptor),对客户端完全透明。
请求 → Middleware 解析 Cookie → 检查剩余有效期 → 不足阈值?静默续签 → 继续处理
常量定义
SESSION_DURATION = 7d // JWT 总有效期
RENEW_THRESHOLD = 3.5d // 续期触发阈值(总时长的一半)
续期逻辑(伪代码)
function middleware(request) {
const token = request.cookies.get('session');
let payload = null;
let newToken = null;
let shouldClear = false;
if (token) {
try {
payload = verifyJWT(token);
const remaining = payload.exp - now();
if (remaining > 0 && remaining < RENEW_THRESHOLD) {
// 剩余不足阈值,静默续期
newToken = signJWT({ user: payload.user });
}
} catch {
// Token 已过期或被篡改,清除 Cookie
shouldClear = true;
}
}
// 路由保护逻辑(检查 payload 是否存在、角色是否正确等)
// ...
const response = next();
if (newToken) {
response.cookies.set('session', newToken, {
expires: SESSION_DURATION,
httpOnly: true,
secure: true,
});
} else if (shouldClear) {
response.cookies.set('session', '', { expires: 0 });
}
return response;
}
关键设计点:
- 先解析,再判断,最后统一写入响应——避免解析失败时 Cookie 操作被丢弃(重定向场景下需要特别注意)
- 过期 Token 自动清除——不留无效 Cookie 在浏览器里,下次请求不会带着一个注定失败的 Token
- 续期不影响请求处理——即使触发续期,当前请求照常处理,新 Token 只是顺便写在响应头里
Cookie 与 JWT 的有效期必须同步
Cookie 的有效期必须覆盖 JWT 的有效期。否则会出现:JWT 还有效,但浏览器已经不发 Cookie 了——等于白签。
建议两者设相同时长,保持一致。
路由覆盖范围
续期需要覆盖所有用户可能访问的路径——页面导航、API 调用、静态资源除外。
matcher = ['/((?!_next/static|_next/image|favicon.ico).*)'];
只有覆盖全站,才能确保"任何访问都续期"。如果 Matcher 只覆盖 /admin 等少数路径,普通页面访问不会触发续期,Session 可能比预期更早过期。
和 Access + Refresh 双 Token 的对比
| 维度 | 滑动续期 | Access + Refresh |
|---|---|---|
| 额外请求 | 0(Middleware 透明处理) | 每次换 Token 需一次额外请求 |
| 存储依赖 | 无(JWT 自包含) | Refresh Token 需数据库或 Redis |
| 主动吊销 | 不能撤销单条 JWT,但窗口短(7d) | 可以删存储中的 Refresh Token 立即吊销 |
| 实现复杂度 | Middleware 几行 | 新增端点、存储层、客户端换 Token 逻辑 |
| 适用场景 | 中小型站点 | 大规模、需要即时吊销的场景 |
如果需要即时吊销能力(比如用户改密码后踢出所有 Session),滑动续期方案也有低成本解法:JWT payload 里带一个 session_version 字段,Middleware 比对用户表中的版本号,不一致就清掉 Cookie。改密码时 bump 版本号,所有旧 Token 即刻失效。这比 Refresh Token 方案简单得多。
性能考量
续期不是每次请求都做。绝大多数请求的流程是:
读 Cookie → 验证签名 → 检查剩余时间 → 不需要续期 → 继续
只有剩余时间不足阈值的那次请求才会触发一次 JWT 签名操作。以 7 天窗口为例,一个每天访问的用户大约每 3-4 天触发一次续期。
JWT 验证是纯计算(HMAC 校验),无 IO;签名同理。开销可以忽略。
安全注意事项
- HttpOnly Cookie——JavaScript 无法读取,XSS 无法窃取 Token。localStorage 存 Token 反而是更危险的选择
- Secure 标记——生产环境 Cookie 必须设
secure: true,防止 HTTP 传输时泄露 - 密钥管理——JWT 签名密钥必须从环境变量注入,硬编码回退值只用于开发环境。生产漏配等于公开密钥
- 窗口长度选择——7 天是常见选择。太长(30d)风险周期大,太短(1d)续期频率高且用户体验差。3-14 天是比较合理的范围
- 签名算法——HS256(对称)足够应对中小站点场景;如果需要多方验签,换 RS256(非对称)
什么时候不该用滑动续期
- 需要即时吊销单条 Token——滑动续期签发后无法主动撤销,只有窗口到期才自然失效。如果业务要求"点一下按钮立刻踢人",需要 Refresh Token 方案或版本号校验
- 超大规模——每条请求过 Middleware 验证 JWT,虽然开销小,但在极高 QPS 下仍有累积成本。此时应考虑网关层集中处理,或缩短 Token 有效期减少验证次数
- 纯 SPA + API 架构——如果前端完全不做 SSR,所有数据通过 API 获取,页面导航不经过服务端,Middleware 拦截不到页面请求。此时需要客户端主动续期(换 Token 端点),本质上就是双 Token 方案了
小结
滑动续期是一种"够用就好"的 Session 管理策略:
- 对用户透明——活跃用户永远不会被踢出,不活跃用户自然过期
- 对服务端轻量——Middleware 几行代码,不需要额外存储
- 安全性可控——7 天窗口 + HttpOnly Cookie + Secure 标记,足够应对大多数场景
它不是万能方案——需要即时吊销或纯 SPA 架构时,双 Token 更合适。但对于 SSR 混合架构的中小站点,滑动续期是投入产出比最高的选择。
