在前后端分离与微服务架构里,JWT 续期与无感刷新是绕不开的工程难题:访问令牌(Access Token)如果过期时间设得太长,一旦泄露风险极高;设得太短,用户又会被频繁踢回登录页。真正优雅的做法是“双令牌 + 前端无感刷新”——用户全程无感知,安全边界却始终收紧。本文从原理到代码,讲透 JWT 续期的几种落地方案。
一、为什么 JWT 需要专门的续期机制
JWT 本身是无状态的:服务端不保存会话,只靠签名校验。这意味着你无法在令牌到期前主动让它失效(除非引入黑名单或状态存储)。因此业界共识是:Access Token 短命(如 15 分钟),再用一个生命周期更长、可轮换的 Refresh Token 去换新的 Access Token。续期机制的本质,是在“安全性”与“用户体验”之间找平衡点。
二、三种续期方案对比
落地时常见三种思路,各有取舍:
| 方案 | 原理 | 优点 | 缺点 | 适用场景 |
| 单纯延长 Access Token | 把 JWT 有效期设成 7 天 | 实现最简单 | 泄露即长期失陷,无法吊销 | 内部工具、Demo |
| 双令牌(Access + Refresh) | 短命 Access + 长命可轮换 Refresh | 可吊销、可 Rotation、安全 | 需存储 Refresh、逻辑稍复杂 | 绝大多数生产系统 |
| 滑动会话(Sliding Session) | 每次活跃请求都延长有效期 | 用户常在线则永不掉线 | 易被“保活脚本”滥用 | 后台管理、长连接场景 |
三、方案一:双令牌(Access + Refresh)模式
3.1 登录时颁发双令牌
登录成功后,服务端同时签出两个令牌:Access Token 用 HS256 短期有效,Refresh Token 存库(或签名态)并长期有效。下面是一段 Node.js / Express 的示意:
const jwt = require("jsonwebtoken");
function issueTokens(user) {
const accessToken = jwt.sign(
{ sub: user.id, scope: user.roles },
process.env.JWT_SECRET,
{ expiresIn: "15m" } // 短期访问令牌
);
const refreshToken = jwt.sign(
{ sub: user.id, typ: "refresh" },
process.env.JWT_REFRESH_SECRET,
{ expiresIn: "7d" } // 长期刷新令牌
);
// Refresh Token 建议写入数据库/Redis,便于吊销与 Rotation
return { accessToken, refreshToken };
}
3.2 用 Refresh 换取新的 Access
当前端拿到 401,就拿着 Refresh Token 调刷新接口,换回新的 Access Token:
app.post("/api/auth/refresh", async (req, res) => {
const { refreshToken } = req.body;
try {
const payload = jwt.verify(refreshToken, process.env.JWT_REFRESH_SECRET);
// 校验 Refresh 是否已被吊销(查库/Redis)
if (!(await isRefreshValid(payload.sub, refreshToken))) {
return res.status(401).json({ code: "REFRESH_INVALID" });
}
const newAccess = jwt.sign(
{ sub: payload.sub },
process.env.JWT_SECRET,
{ expiresIn: "15m" }
);
res.json({ accessToken: newAccess });
} catch (e) {
res.status(401).json({ code: "REFRESH_EXPIRED" });
}
});
3.3 Refresh Token Rotation(轮换)
每次刷新都签发一个新的 Refresh Token,并让旧的立即失效。这样即便 Refresh 被窃取,攻击者也只能在你刷新前的极短窗口内使用一次,下次正常刷新就会让被盗令牌作废——这是防重放的关键。
// 刷新成功后执行 Rotation
async function rotateRefresh(userId, oldToken) {
await revokeRefresh(userId, oldToken); // 吊销旧令牌
const newRefresh = signRefresh(userId); // 签发新令牌
await storeRefresh(userId, newRefresh);
return newRefresh;
}
四、方案二:滑动会话(Sliding Session)
滑动会话适合后台管理系统:只要用户在阈值内(如 30 分钟)有操作,就顺延令牌有效期。实现上,可在每次鉴权成功后用 Redis 的 EXPIRE 续命。注意要设“绝对上限”——例如最长 8 小时必须重新登录,防止令牌被脚本永久保活。
滑动会话的陷阱在于“活跃判定”。如果把“任何请求”都当作活跃信号,那么一个被植入的轮询脚本就能让会话永远在线,等于变相撤销了过期机制。正确做法是只对“有效用户操作”(如页面交互、真实接口调用)续期,且每次续期都重新计算“空闲时长”而非简单叠加。这样既能让真人在后台挂着不掉线,又能让长期无操作的会话自然失效。
五、前端无感刷新实现(核心)
5.1 请求拦截器统一注入
所有请求自动带上 Access Token,遇到 401 才触发刷新。难点在于:同一时刻可能多个请求并发 401,若每个都去刷新,会打爆刷新接口。正确做法是维护一个“刷新中队列”。
5.2 401 触发刷新队列
let refreshing = null;
const queue = [];
function refreshAccessToken() {
if (refreshing) return refreshing; // 复用同一刷新 promise
refreshing = axios.post("/api/auth/refresh", {
refreshToken: getRefresh()
}).then(res => {
setAccess(res.data.accessToken);
queue.forEach(cb => cb(res.data.accessToken));
queue.length = 0;
return res.data.accessToken;
}).finally(() => { refreshing = null; });
return refreshing;
}
// 响应拦截:401 时排队等待刷新完成再重试
axios.interceptors.response.use(
r => r,
async err => {
const { config, response } = err;
if (response && response.status === 401 && !config._retry) {
config._retry = true;
const token = await refreshAccessToken();
config.headers.Authorization = `Bearer ${token}`;
return axios(config); // 用新令牌重试原请求
}
return Promise.reject(err);
}
);
这套“单飞刷新 + 请求排队”的模式,能让用户在令牌过期瞬间毫无察觉,页面不跳登录、请求不报错。
六、网关与 Nginx 的配合
在微服务架构里,刷新接口通常躲在网关之后。用 Nginx 反向代理 把 /api/auth/* 统一转发到认证服务,并给刷新路由单独配较长的超时与限流,避免刷新风暴把网关打挂。若采用 Spring Cloud 微服务治理 体系,还可把刷新动作下沉到网关过滤器,统一处理鉴权与续期。
七、WebSocket 中的 Token 续期
长连接同样面临令牌过期。WebSocket 断线重连 时,客户端应在握手 URL 或子协议里携带最新 Access Token;若连接期间令牌到期,服务端应下发一个“需刷新”的控制帧,客户端换取新令牌后重新握手,而不是默默断开。这与 HTTP 的无感刷新思路一脉相承。
八、五个生产环境高频坑
| 现象 | 根因 | 处置 |
| 刷新接口被并发打爆 | 多请求同时 401 各自刷新 | 单飞刷新 + 请求队列(见第五节) |
| Refresh 被盗用无忧 | 未做 Rotation | 每次刷新轮换并吊销旧令牌 |
| 用户永远踢不下线 | 滑动会话无绝对上限 | 加最大存活时间,强制重登 |
| 多标签页互相踢 | 各标签独立 Refresh 状态 | Refresh 存同源存储,或 BroadcastChannel 同步 |
| 服务端重启后 Refresh 全失效 | Refresh 存内存未持久化 | 落 Redis/数据库,见 Seata 分布式事务 的存储思路 |
九、Refresh Token 该存在哪里:Cookie 还是 localStorage
续期方案再完善,Refresh Token 的存储位置也直接决定安全性。最常见的两个选择是 localStorage 与 httpOnly Cookie:前者好处是前端 JS 能直接读取、便于“无感刷新”自动携带,但一旦遇到 XSS,令牌会被脚本一锅端;后者由浏览器托管、JS 读不到,天然免疫 XSS,却要额外防范 CSRF。
工程上的折中方案是:把 Refresh Token 放进 httpOnly + Secure + SameSite=Strict 的 Cookie,刷新接口由浏览器自动带上,前端完全不碰它;而短期 Access Token 仍可放在内存(或 sessionStorage)里,由拦截器管理。这样既享受了 Cookie 的抗 XSS 能力,又保留了无感刷新的体验。若坚持用 localStorage,务必收紧 CSP、对所有输出做转义,并考虑给 Refresh Token 绑定设备指纹,降低单点泄露的影响面。
十、小结
JWT 续期与无感刷新的最佳实践可以浓缩为一句话:Access Token 短命、Refresh Token 可轮换、前端单飞刷新 + 队列重试。相比单纯延长 JWT 有效期,双令牌方案在“泄露可控、可即时吊销、用户无感”三方面全面胜出。与 Spring Security 认证授权与 JWT 实战 的签发逻辑配合,就能搭出一套既安全又顺滑的认证体系。真正的好体验,是用户永远感觉不到令牌的存在。




