无感刷新的实现:Cookie 版 token/refresh_token 实现
一个生产级可用的无感刷新实现
🕐
安装依赖
实际项目中,根据自己项目中合适的依赖处理。
# js-cookie 用来安全操作 cookie
# singleflight: 保证并发场景
npm install axios @jswork/singleflight js-cookie用户登录
要点:后端种下 httpOnly Cookie

详细步骤
- 前端 → 发账号密码 → 后端
- 后端生成:
access_token(JWT,短有效期)
refresh_token(JWT,长有效期) - 后端响应头写入:
Set-Cookie: refresh_token=AFSJK...; HttpOnly; Path=/; Domain=.yourdomain.com - 后端只返回 access_token 给前端
{ "access_token": "xxxxxxxx" } - 前端把 access_token 存在 内存或 Cookie
- refresh_token → 前端完全碰不到
后端如何设置 httpOnly
Node.js Express 版(最常用)
// 登录接口
app.post("/api/login", (req, res) => {
const { username, password } = req.body;
// 1. 验证账号密码
// 2. 生成 JWT
const access_token = jwt.sign({ userId }, process.env.JWT_SECRET, { expiresIn: "1h" });
const refresh_token = jwt.sign({ userId }, process.env.JWT_REFRESH_SECRET, { expiresIn: "7d" });
// ✅ 关键:后端直接种 httpOnly Cookie
res.cookie("refresh_token", refresh_token, {
httpOnly: true, // 前端JS无法读取
secure: process.env.NODE_ENV === "production", // 生产https才发送
sameSite: "strict", // 防CSRF
maxAge: 7 * 24 * 60 * 60 * 1000, // 7天
domain: ".yourdomain.com", // 跨子域名共享(关键!)
path: "/"
});
// ✅ 只返回 access_token 给前端
res.json({ access_token });
});Java SpringBoot 版
@PostMapping("/api/login")
public ResponseEntity<?> login(@RequestBody LoginDTO dto, HttpServletResponse response) {
// 生成 JWT
String accessToken = jwtUtil.createAccessToken(user);
String refreshToken = jwtUtil.createRefreshToken(user);
// 后端设置 httpOnly Cookie
ResponseCookie cookie = ResponseCookie.from("refresh_token", refreshToken)
.httpOnly(true)
.secure(true)
.sameSite("Strict")
.domain(".yourdomain.com")
.path("/")
.maxAge(7 * 24 * 60 * 60)
.build();
response.addHeader("Set-Cookie", cookie.toString());
return ResponseEntity.ok(Map.of("access_token", accessToken));
}无感刷新流程

拦截操作
import axios from 'axios'
import SingleFlight from '@jswork/singleflight'
import Cookies from 'js-cookie'
// ======================
// 1. Cookie 存储工具(安全版)
// ======================
const tokenStorage = {
// access_token 存在 可前端读取 cookie(用于header)
getAccess: () => Cookies.get('access_token'),
setAccess: (t: string) => {
Cookies.set('access_token', t, {
path: '/',
expires: 0.5, // 12 小时
sameSite: 'strict',
secure: process.env.NODE_ENV === 'production' // 生产 https
})
},
// refresh_token 必须存在 httpOnly cookie(前端无法读取!)
// 前端只需要知道:有/没有,不需要知道值
hasRefresh: () => !!document.cookie.includes('refresh_token'),
// 登出清空
clear: () => {
Cookies.remove('access_token')
// refresh_token 由后端清空,前端删不掉
location.href = '/login'
}
}
// ======================
// 2. 单飞:防并发刷新
// ======================
const sf = new SingleFlight<{ access_token: string }>()
// ======================
// 3. 刷新 token(核心)
// ======================
async function refreshToken() {
return sf.run('refresh', async () => {
// ✅ 关键:refresh_token 自动由浏览器携带在 cookie 里
const res = await axios.post('/api/auth/refresh', null, {
withCredentials: true // 必须!跨域带 cookie
})
const { access_token } = res.data
tokenStorage.setAccess(access_token)
return res.data
})
}
// ======================
// 4. Axios 请求实例
// ======================
const request = axios.create({
baseURL: '/api',
timeout: 10000,
withCredentials: true // ✅ 所有请求自动带 cookie
})
// 请求拦截:带上 access_token
request.interceptors.request.use((config) => {
const token = tokenStorage.getAccess()
if (token) {
config.headers.Authorization = `Bearer ${token}`
}
return config
})
// 响应拦截:401 自动刷新
request.interceptors.response.use(
res => res.data,
async error => {
const { response, config } = error
if (!response || response.status !== 401) return Promise.reject(error)
if (config.url === '/auth/refresh') {
tokenStorage.clear()
return Promise.reject(error)
}
try {
await refreshToken()
config.headers.Authorization = `Bearer ${tokenStorage.getAccess()}`
return request(config)
} catch (e) {
tokenStorage.clear()
return Promise.reject(e)
}
}
)
export default request登录逻辑(后端种 Cookie)
// 登录时,后端直接 Set-Cookie 种下去
// 前端不需要存 refresh_token!完全由浏览器管理
async function login(account, pwd) {
const res = await axios.post('/api/auth/login', { account, pwd }, {
withCredentials: true
})
// 只需要存 access_token
tokenStorage.setAccess(res.data.access_token)
}refresh_token 存在 Cookie(httpOnly) vs localStorage
优点(Cookie 完胜,生产必须用)
防 XSS 攻击(最重要)
httpOnly: true→ 前端 JS 永远读不到 refresh_token- 就算页面被注入脚本,也偷不走最高权限凭证
- localStorage 会被 XSS 直接偷走所有 token
自动携带,无需手动管理
- 浏览器自动在请求头带上 Cookie
- 刷新接口不需要前端传 refresh_token
- 代码更干净、更安全
后端可控性更强
- 可设置
secure(仅 https 传输) - 可设置
sameSite(防 CSRF) - 可设置
httpOnly(防窃取) - 可主动清空、强制过期、强制下线
天然支持跨域 + 单点登录
- 同域名下多系统自动共享登录态
- localStorage 跨域完全隔离
2. 缺点(几乎可以忽略)
- 需要配置 withCredentials: true
- 跨域时后端要配置
Access-Control-Allow-Credentials: true
- 跨域时后端要配置
- 前端无法读取 refresh_token
- 但这本来就是安全特性,不是缺点
- Cookie 有大小限制(4k)
- 存 token 完全够用
最安全的企业级标准结构
- access_token → 普通 Cookie(前端可读,用于 header)
- refresh_token → httpOnly Cookie(前端不可读,浏览器自动带)
为什么这是最佳实践?
- 短 token 过期快 → 降低风险
- 长 token 前端碰不到 → 绝对安全
- 刷新无感 → 用户无感知
- 并发只刷一次 → 稳定不崩溃
- XSS 偷不走 → 符合等保合规
使用 sf 防抖
原理图如下,下面还有示例代码。

import SingleFlight from '@jswork/singleflight';
const sf = new SingleFlight<string>();
let token = '';
async function refreshToken(): Promise<string> {
return sf.run('refresh-token', async () => {
const res = await fetch('/api/refresh-token', {
method: 'POST',
headers: { Authorization: `Bearer ${token}` },
});
const data = await res.json();
token = data.token;
return token;
});
}
async function request(url: string, options?: RequestInit) {
const res = await fetch(url, {
...options,
headers: { ...options?.headers, Authorization: `Bearer ${token}` },
});
if (res.status === 401) {
const newToken = await refreshToken();
return fetch(url, {
...options,
headers: { ...options?.headers, Authorization: `Bearer ${newToken}` },
});
}
return res;
}现实世界
- 后端直接不返回
refresh_token,前端有请求存在,就直接自动刷新(401) token → 前端无感的刷新机制 - 还有: 前端可以建立中间层,不需要后端来写 cookie(如 nextjs 来做这种事情)