无感刷新的实现:Cookie 版 token/refresh_token 实现

一个生产级可用的无感刷新实现

🕐

安装依赖

实际项目中,根据自己项目中合适的依赖处理。

# js-cookie 用来安全操作 cookie
# singleflight: 保证并发场景
npm install axios @jswork/singleflight js-cookie

用户登录

要点:后端种下 httpOnly Cookie

详细步骤

  1. 前端 → 发账号密码 → 后端
  2. 后端生成:
    access_token(JWT,短有效期)
    refresh_token(JWT,长有效期)
  3. 后端响应头写入:Set-Cookie: refresh_token=AFSJK...; HttpOnly; Path=/; Domain=.yourdomain.com
  4. 后端只返回 access_token 给前端 { "access_token": "xxxxxxxx" }
  5. 前端把 access_token 存在 内存或 Cookie
  6. 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. 缺点(几乎可以忽略)

  1. 需要配置 withCredentials: true
    • 跨域时后端要配置 Access-Control-Allow-Credentials: true
  2. 前端无法读取 refresh_token
    • 但这本来就是安全特性,不是缺点
  3. 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 来做这种事情)