一句话:Access Token 短、Refresh Token 长——用户无感续期,关键是并发 401 时只刷一次。
写在前面
JWT 登录后,如果 Access Token 有效期设太长,泄露风险大;设太短,用户会频繁掉线。常见做法是 双 Token:
| Token | 典型有效期 | 用途 |
|---|---|---|
| Access Token | 30 分钟 | 每次 API 请求携带 |
| Refresh Token | 7 天 | 仅用于换新 Access Token |
「无感刷新」指:Access Token 过期时,前端自动用 Refresh Token 换新,用户不必重新登录。难点在于——页面往往同时发出多个请求,它们可能同时收到 401,若每个都独立去 refresh,会导致 Token 轮换混乱。
本篇基于本博客 blog-home-nuxt + blog-server 的真实实现,讲清楚前后端分工与请求队列设计。
读完你能:画出 401 → refresh → 重试 的时序;在本仓库定位关键代码;避免「并发 refresh」经典坑。
系列导航:下一篇:大文件切片上传
整体流程
后端:NestJS 签发与刷新
双 Token 签发
登录成功后,UserService.certificate 同时签发三个 token(兼容历史字段 + 新双 token):
typescript
// blog-server/src/modules/features/user/user.service.ts
const accessToken = this.jwtService.sign(payload, { expiresIn: '0.5h' });
const refreshToken = this.jwtService.sign(payload, { expiresIn: '7d' });
return { token, accessToken, refreshToken };
- Access Token:0.5 小时,用于日常 API 鉴权。
- Refresh Token:7 天,仅用于换新。
Refresh 接口与一次性校验
GET /user/refresh?token=... 核心逻辑:
- 对 refresh token 做 SHA256 哈希,查 Redis 黑名单——已用过则拒绝(防止重放)。
jwtService.verify校验签名与过期时间。- 将当前 refresh token 写入黑名单,TTL 与 token 剩余寿命对齐。
- 重新
certificate,返回全新 access + refresh。
typescript
// 简化示意
const tokenHash = createHash('sha256').update(token).digest('hex');
const used = await this.redisService.get(`auth:refresh:blacklist:${tokenHash}`);
if (used) throw new UnauthorizedException('refresh token 已失效,请重新登录');
const user = this.jwtService.verify(token);
await this.redisService.set(refreshBlacklistKey, '1', ttlSec);
return { ...await this.certificate(user), user };
设计取舍:每次 refresh 都会轮换 refresh token,并拉黑旧 token——即使旧 token 被截获,也只能用一次。
前端:Nuxt 3 请求封装
实现位于 blog-home-nuxt/api/request.ts,基于 $fetch.create + onResponseError。
三个全局变量
typescript
let refreshing = false; // 是否正在刷新
let queue: PendingTask[] = []; // 等待重试的请求
每个 $http 调用返回一个 Promise;401 时要么触发 refresh,要么把自己挂进队列。
场景 1:第一个 401 —— 发起 refresh
typescript
if (status === 401 && !url.includes('/user/refresh')) {
refreshing = true;
const res = await refreshToken();
refreshing = false;
if (res) {
queue.forEach(({ config, url, fn }) => {
config.headers.Authorization = getTk();
fn(apiFetch(url, config));
});
queue = [];
safeResolve(await apiFetch(url, getDTconfig()));
} else {
// 清除本地 token,提示重新登录
}
}
refresh 成功后:先重放队列里积压的请求,再重试当前触发 401 的请求。
场景 2:refresh 进行中 —— 入队等待
typescript
if (refreshing) {
queue.push({
config: getDTconfig(),
url,
fn: safeResolve, // refresh 完成后由 fn 把 Promise 置为 fulfilled
});
return; // 关键:不再往下走,避免重复 refresh
}
为什么必须 return:若继续执行,每个 401 都会各自 resolve,导致重复发请求或状态错乱。
refreshToken 函数
typescript
async function refreshToken() {
const refreshToken = getToken(RefreshTokenKey);
const res = await get('/user/refresh', { token: refreshToken });
setToken(TokenKey, res.accessToken);
setToken(RefreshTokenKey, res.refreshToken, '', 7);
// 同步 Pinia / useToken / useUserInfo
return res;
}
本地 Cookie + 响应式 state 同步更新,重放请求时 getTk() 能拿到新 access token。
踩坑与注意
- exclude
/user/refresh:refresh 接口本身 401 不能再触发 refresh,否则死循环。 - 队列里存的是
resolve而非立即重请求:保证外层await $http()的 Promise 在 token 就绪后才 fulfilled。 - Refresh Token 轮换:后端每次 refresh 都发新 refresh token,前端必须覆盖存储,否则下次 refresh 会命中黑名单。
- SSR 注意:token 存在 Cookie 时,服务端请求也要能读到;本博客 C 端 API 主要在客户端发,SSR 公开接口无需 token。
- silent 模式:部分页面(如 404)可传
silent: true避免 SSR 弹 toast——与 refresh 逻辑独立。
小结
- 双 Token 分离「高频鉴权」与「低频续期」,安全性与体验兼顾。
- 无感刷新的本质是 单飞(single-flight)refresh + 请求队列。
- 后端用 Redis 黑名单保证 refresh token 一次性,防止重放。
- 本仓库可直接对照:
api/request.ts(前端)、user.service.ts的refresh(后端)。
延伸阅读
- 下一篇:大文件切片上传
- 认证模块自文档:博客系统自文档 · 认证与登录
- NestJS JWT 文档

全部评论(0)