前端實(shí)現(xiàn)無(wú)感刷新Token的方法與避坑指南
刷新 Token 不是“過(guò)期就重新登錄”,而是讓用戶毫無(wú)感知地繼續(xù)使用。
可惜,大多數(shù)項(xiàng)目還在用 401 跳登錄 粗暴處理——這根本不是用戶體驗(yàn),這是放棄治療。
在現(xiàn)代 Web 應(yīng)用中,用戶登錄后通常會(huì)獲得一對(duì) Token:
- Access Token(短期有效,如 15 分鐘)
- Refresh Token(長(zhǎng)期有效,如 7 天)
當(dāng) Access Token 過(guò)期時(shí),理想狀態(tài)是:前端自動(dòng)用 Refresh Token 換取新 Token,并重試原請(qǐng)求——整個(gè)過(guò)程用戶無(wú)感,頁(yè)面不跳轉(zhuǎn)、操作不中斷。
但現(xiàn)實(shí)呢?
“Token 過(guò)期 → 彈出登錄框 → 用戶罵一句‘怎么又登出了’ → 關(guān)掉頁(yè)面走人。”
今天,我們就來(lái)徹底搞懂:如何真正實(shí)現(xiàn)“無(wú)感刷新”Token?為什么 90% 的實(shí)現(xiàn)都有致命缺陷?
錯(cuò)誤做法一:在每個(gè)接口里手動(dòng)判斷 401
// 千萬(wàn)別這么寫!
fetch('/api/user')
.then(res => {
if (res.status === 401) {
// 重新登錄 or 刷新 token?
window.location.href = '/login';
}
});問(wèn)題在哪?
- 每個(gè)接口都要重復(fù)寫邏輯;
- 如果多個(gè)請(qǐng)求同時(shí) 401,會(huì)觸發(fā)多次刷新,甚至多次跳登錄;
- 完全無(wú)法做到“無(wú)感”。
錯(cuò)誤做法二:全局?jǐn)r截 401 后直接刷新 Token 并重試一次
這是目前最“主流”的錯(cuò)誤方案:
// 偽代碼:看似聰明,實(shí)則危險(xiǎn)
axios.interceptors.response.use(
res => res,
async (error) => {
if (error.response.status === 401) {
const newToken = await refreshToken(); // 獲取新 token
saveToken(newToken);
// 用新 token 重試原請(qǐng)求
return axios(error.config);
}
}
);
表面看沒問(wèn)題,但隱藏三大坑
坑 1:并發(fā)請(qǐng)求雪崩
當(dāng)頁(yè)面剛加載,10 個(gè)接口同時(shí)發(fā)起,而此時(shí) Token 已過(guò)期 ——10 個(gè)請(qǐng)求全部返回 401 → 觸發(fā) 10 次 refreshToken() → 后端收到 10 個(gè)刷新請(qǐng)求!
后果:
- 后端可能拒絕重復(fù)刷新(安全策略);
- Refresh Token 被提前消耗,后續(xù)真失效;
- 用戶反而被踢下線。
坑 2:Refresh Token 泄露風(fēng)險(xiǎn)
如果前端把 Refresh Token 存在 localStorage,一旦 XSS 攻擊成功,攻擊者可長(zhǎng)期盜用賬號(hào)。
安全最佳實(shí)踐:Refresh Token 應(yīng)僅存于 HttpOnly Cookie,前端不可讀!
但上述方案要求前端“拿到新 token”,這就逼你把 Refresh Token 暴露給 JS —— 安全與功能不可兼得?
坑 3:無(wú)限重試死循環(huán)
如果 refreshToken() 本身也返回 401(比如 Refresh Token 也過(guò)期了),重試原請(qǐng)求 → 又 401 → 再刷新 → 再 401 → ……
瀏覽器卡死,內(nèi)存飆升。
正確方式:用“鎖機(jī)制 + 隊(duì)列 + 安全存儲(chǔ)”三位一體
要實(shí)現(xiàn)真正的無(wú)感刷新,必須同時(shí)解決:
- 并發(fā)控制(只刷一次)
- 安全存儲(chǔ)(Refresh Token 不暴露給 JS)
- 失敗兜底(Refresh 失敗時(shí)優(yōu)雅降級(jí))
第一步:后端配合 —— Refresh Token 存 HttpOnly Cookie
HTTP/1.1 200 OK
Set-Cookie: refreshToken=abc123; HttpOnly; Secure; SameSite=Strict; Path=/auth
前端永遠(yuǎn)拿不到 refreshToken,但每次請(qǐng)求會(huì)自動(dòng)攜帶。
第二步:前端實(shí)現(xiàn)“單例刷新鎖 + 請(qǐng)求隊(duì)列”
let isRefreshing = false;
let refreshPromise = null;
const failedQueue = [];
// 重試隊(duì)列中的請(qǐng)求
const processQueue = (error, token = null) => {
failedQueue.forEach(({ resolve, reject }) => {
if (error) {
reject(error);
} else {
resolve(token);
}
});
failedQueue.length = 0;
};
axios.interceptors.response.use(
response => response,
async (error) => {
const originalRequest = error.config;
if (error.response?.status === 401 && !originalRequest._retry) {
if (isRefreshing) {
// 已在刷新中,將請(qǐng)求加入隊(duì)列,等待新 token
return new Promise((resolve, reject) => {
failedQueue.push({ resolve, reject });
}).then(token => {
originalRequest.headers['Authorization'] = `Bearer ${token}`;
return axios(originalRequest);
});
}
originalRequest._retry = true;
isRefreshing = true;
try {
// 調(diào)用刷新接口(后端從 Cookie 讀 refreshToken)
const { data } = await axios.post('/auth/refresh');
const newAccessToken = data.accessToken;
// 通知所有排隊(duì)的請(qǐng)求
processQueue(null, newAccessToken);
// 重試當(dāng)前請(qǐng)求
originalRequest.headers['Authorization'] = `Bearer ${newAccessToken}`;
return axios(originalRequest);
} catch (refreshError) {
// 刷新失?。呵蹇毡镜厣矸荩D(zhuǎn)登錄
clearAuth();
processQueue(refreshError, null);
window.location.href = '/login';
return Promise.reject(refreshError);
} finally {
isRefreshing = false;
refreshPromise = null;
}
}
return Promise.reject(error);
}
);
關(guān)鍵設(shè)計(jì)解析
| 機(jī)制 | 作用 |
|---|---|
| isRefreshing 鎖 | 確保同一時(shí)間只發(fā)起一次刷新 |
| failedQueue 隊(duì)列 | 緩存所有因 401 失敗的請(qǐng)求,等新 token 到手后批量重試 |
| _retry 標(biāo)記 | 防止重試后的請(qǐng)求再次進(jìn)入刷新邏輯 |
| HttpOnly Cookie | 保護(hù) Refresh Token 不被 XSS 竊取 |
安全補(bǔ)充:前端 Token 存儲(chǔ)建議
| Token 類型 | 推薦存儲(chǔ)方式 | 原因 |
|---|---|---|
| Access Token | 內(nèi)存(JS 變量)或 sessionStorage | 短期有效,避免持久化泄露 |
| Refresh Token | HttpOnly Cookie | 前端不可讀,防 XSS |
切勿將任何 Token 存入 localStorage!這是 XSS 攻擊的黃金目標(biāo)。
如何測(cè)試你的刷新邏輯
- 手動(dòng)將 Access Token 設(shè)為過(guò)期;
- 快速點(diǎn)擊多個(gè)按鈕,觸發(fā)并發(fā)請(qǐng)求;
- 觀察 Network 面板:
- 是否只調(diào)用了一次
/auth/refresh? - 所有原請(qǐng)求是否最終成功?
- 是否只調(diào)用了一次
- 模擬 Refresh Token 失效,是否跳轉(zhuǎn)登錄?
結(jié)語(yǔ)
“無(wú)感刷新 Token”不是炫技,而是對(duì)用戶體驗(yàn)和系統(tǒng)安全的基本尊重。那些讓用戶頻繁重新登錄的產(chǎn)品,不是技術(shù)做不到,而是沒把用戶當(dāng)回事。
真正的專業(yè),藏在細(xì)節(jié)里:一個(gè)鎖、一個(gè)隊(duì)列、一個(gè) HttpOnly Cookie —— 就是 10% 正確方案 與 90% 錯(cuò)誤實(shí)現(xiàn)的分水嶺。
你的項(xiàng)目還在用“401 就跳登錄”嗎?是時(shí)候升級(jí)了。
歡迎轉(zhuǎn)發(fā)給那個(gè)總說(shuō)“Token 過(guò)期就讓用戶重新登錄”的同事。
到此這篇關(guān)于前端實(shí)現(xiàn)無(wú)感刷新Token的方法與避坑指南的文章就介紹到這了,更多相關(guān)前端無(wú)感刷新token內(nèi)容請(qǐng)搜索腳本之家以前的文章或繼續(xù)瀏覽下面的相關(guān)文章希望大家以后多多支持腳本之家!
相關(guān)文章
微信小程序的宿主環(huán)境實(shí)現(xiàn)代碼
這篇文章主要介紹了微信小程序的宿主環(huán)境,包括scroll-view 組件的基本使用,text 組件的基本使用及rich-text 組件的基本使用,本文通過(guò)示例代碼給大家介紹的非常詳細(xì),需要的朋友可以參考下2022-10-10
JS實(shí)現(xiàn)選項(xiàng)卡插件的兩種寫法(jQuery和class)
這篇文章主要為大家詳細(xì)介紹了JS實(shí)現(xiàn)選項(xiàng)卡插件的兩種寫法:jQuery和class,文中示例代碼介紹的非常詳細(xì),具有一定的參考價(jià)值,感興趣的小伙伴們可以參考一下2020-12-12
JavaScript與DropDownList 區(qū)別分析
大家都知道,.NET中一些Web服務(wù)器控件解析并編譯,最終被渲染的時(shí)候,其實(shí)是轉(zhuǎn)化成了普通的html控件。2010-01-01
使用Webpack壓縮與轉(zhuǎn)譯JavaScript代碼的操作方法
在Web開發(fā)中,代碼的性能和加載時(shí)間是用戶體驗(yàn)的重要組成部分,為此,將JavaScript代碼壓縮和優(yōu)化是發(fā)布前一個(gè)必不可少的步驟,所以本文給大家介紹了如何使用Webpack壓縮與轉(zhuǎn)譯JavaScript代碼,需要的朋友可以參考下2024-05-05
innerHTML動(dòng)態(tài)添加html代碼和腳本兼容多個(gè)瀏覽器
innerHTML動(dòng)態(tài)添加html代碼和腳本,給某個(gè)元素的innerHTML賦值,并使得值中的js代碼有效且兼容多個(gè)瀏覽器,很棒的一個(gè)方法2014-10-10
前端JavaScript經(jīng)典之Promise詳解
Promise是為了解決回調(diào)地獄問(wèn)題而誕生的,它提供了優(yōu)雅的異步回調(diào)解決方案,這篇文章主要介紹了前端JavaScript經(jīng)典之Promise的相關(guān)資料,需要的朋友可以參考下2024-09-09

