前端无感刷新Token

  在现代 Web 应用中,身份验证(Authentication)权限控制(Authorization)是保障安全的核心机制。通常,前端登录成功后会拿到一个 access_token(访问令牌)用于后续请求。但为了安全,这个 access_token 的有效期往往较短,比如 15 分钟或 1 小时。如果不做处理,Token 过期后用户就需要重新登录,这会导致用户体验变差。为了解决这个问题,我们引入了“无感刷新 Token”机制,即在不打断用户操作的前提下,悄悄完成 Token 的续期,让经常使用的用户察觉不到 Token 的生命周期。

是什么

无感刷新 Token(Silent Token Refresh),是指在不弹窗、不跳转的情况下,在 Token 即将过期或请求返回 401 时自动进行 Token 刷新,并重新发起之前失败的请求。

主要目标:

  • 保障用户登录状态持续有效
  • 避免频繁弹窗提示重新登录
  • 提升用户体验和系统稳定性

为什么不直接延长 Access Token 有效期:

直接把 Token 设置成一天甚至一周不过期,会引发两个问题:

  1. 安全性降低:Access Token 通常是无状态的 JWT,携带用户身份信息,若被窃取,在有效期内都可被滥用。
  2. 不可控注销:你无法在服务端主动让一个 JWT 失效,除非维护一个黑名单(引入了状态和复杂性)。

因此,最佳实践是:

  • 使用短效的 access_token(如 15 分钟)
  • 配合一个长效的 refresh_token(如 7 天)进行续期

基本流程

通常,刷新 Token 的接口只发送 refresh_token,服务端验证后返回新的 Token 对。

1
2
3
4
5
6
7
8
9
10
11
12
用户登录

拿到 access_token & refresh_token

携带 access_token 访问接口

接口返回 401(Token 过期)

尝试用 refresh_token 获取新的 access_token

成功:更新 Token,重试请求
失败:跳转登录页

方案设计(推荐)

接口约定

  • 登录接口返回:

    1
    2
    3
    4
    5
    {
    "access_token": "xxx",
    "refresh_token": "yyy",
    "expires_in": 900
    }
  • 刷新 Token 接口(POST /auth/refresh): 请求体/请求头携带 refresh_token

    成功返回新的 token 对,失败返回 401,提示客户端清除状态并跳转登录。

前端设计思路:

  • 拦截请求和响应,统一处理 Token 添加和刷新
  • 用 Axios 拦截器在 401 时触发刷新逻辑
  • 刷新成功后重发原请求
  • 使用队列或锁避免并发刷新

前端实现(示例)

以下以 Axios + React 为例

Token 存储

类型 存储位置 说明
access_token 内存 / Cookie 避免长久保存,防 XSS
refresh_token HttpOnly Cookie 后端设置,防止 JS 读取(推荐)
HttpOnly Cookie 全解析

Axios 拦截器

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
import axios from 'axios'

let isRefreshing = false
let refreshSubscribers: ((token: string) => void)[] = []

const api = axios.create({
baseURL: '/api',
})

// 添加 access_token 到请求头
api.interceptors.request.use((config) => {
const token = localStorage.getItem('access_token')
if (token) {
config.headers.Authorization = `Bearer ${token}`
}
return config
})

// 响应拦截器
api.interceptors.response.use(
(res) => res,
async (error) => {
const { config, response } = error
if (response?.status === 401 && !config._retry) {
config._retry = true

if (!isRefreshing) {
isRefreshing = true
try {
const { data } = await axios.post('/api/auth/refresh', {
refresh_token: localStorage.getItem('refresh_token'),
})

localStorage.setItem('access_token', data.access_token)
isRefreshing = false

// 依次执行等待队列中的请求
refreshSubscribers.forEach((cb) => cb(data.access_token))
refreshSubscribers = []

return api(config) // 重新发起原始请求
} catch (err) {
isRefreshing = false
localStorage.clear()
window.location.href = '/login'
return Promise.reject(err)
}
}

// 返回一个挂起的 Promise,等待刷新成功后重新发起请求
return new Promise((resolve) => {
refreshSubscribers.push((newToken) => {
config.headers.Authorization = `Bearer ${newToken}`
resolve(api(config))
})
})
}

return Promise.reject(error)
}
)

export default api

在代码实现时需注意:

  1. 避免频繁刷新

    在多个请求同时返回 401 时,如果不加锁会导致多个刷新请求发出。可用 isRefreshing 和队列来避免。

  2. 刷新接口失败处理

    建议跳转登录页,并清空所有 Token 数据。可以使用全局状态管理(如 Redux/Zustand)清空登录状态。

  3. 刷新策略选择

    除了在 401 时刷新,也可以提前几分钟自动刷新,可搭配定时器 + expires_in 实现。

作者

Fu9Zhou

发布于

2025-07-24

许可协议