【後端架構系列 04】前端生存指南:Axios 攔截器與無感換證 (Silent Refresh)
01. 前言:最後一哩路
在前面三篇,我們已經在後端搭建了一座堅固的堡壘:
- JWT 負責快速通關。
- Redis 負責即時撤銷。
- HttpOnly Cookie 保護 Refresh Token。
但這座堡壘如果不搭配正確的前端鑰匙,使用者體驗將會是一場災難。想像一下,使用者正在填寫一張長表單,Access Token 剛好在第 14 分鐘過期,他按送出,結果跳出 401 Unauthorized,畫面被強制轉回登入頁,表單資料全沒了。
這絕對是被開除的節奏。
今天我們要實作 「無感換證 (Silent Refresh)」:當 Access Token 過期,前端要在使用者毫無察覺的情況下,自動去換發新 Token,並 「重試 (Retry)」 剛剛失敗的請求。
02. 核心策略:記憶體與攔截器
回顧一下我們的儲存策略:
- Refresh Token: 瀏覽器自動管理的 HttpOnly Cookie (前端 JS 碰不到)。
- Access Token: 存放在 記憶體 (Variable / React Context) 中。
這意味著,當使用者按下 F5 重新整理網頁,記憶體裡的 Access Token 會消失。所以我們的 App 啟動流程必須是:
- App Init: 頁面載入。
- Check Auth: 嘗試呼叫
/auth/refreshAPI。 - Success: 後端驗證 Cookie,回傳新的 Access Token -> 存入記憶體 -> 顯示登入後畫面。
- Failure: 導向登入頁。
03. 實戰:Axios 攔截器 (Interceptor)
這是本篇的精華。我們需要兩個攔截器:
- Request Interceptor: 每個請求自動帶上 Access Token。
- Response Interceptor: 攔截 401 錯誤,換證並重試。
安裝
npm install axios
定義 API Client (src/api/axios.ts)
import axios, { AxiosRequestConfig, AxiosError } from 'axios';
// 1. 建立實例
const api = axios.create({
baseURL: process.env.REACT_APP_API_URL || 'http://localhost:3000/api',
withCredentials: true, // 關鍵!讓瀏覽器自動帶上 HttpOnly Cookie
});
// 定義一個變數存 Access Token (取代 LocalStorage)
let accessToken = '';
export const setAccessToken = (token: string) => {
accessToken = token;
};
// 2. Request 攔截器:注入 Token
api.interceptors.request.use(
(config) => {
if (accessToken) {
config.headers.Authorization = `Bearer ${accessToken}`;
}
return config;
},
(error) => Promise.reject(error)
);
// ... 下面是重頭戲 ...
04. 解決「併發請求」的地獄 (The Race Condition)
這是 90% 前端工程師會踩的坑。
情境: 頁面初始化時,同時發出了 5 個 API 請求 (Get User, Get News, Get Config...),此時 Access Token 剛好過期。
錯誤的實作:
5 個請求都收到 401 -> 觸發 5 次 /refresh API -> 災難發生!
因為我們在後端做了 Token Rotation (旋轉機制),第一個 Refresh 請求成功後,舊的 Refresh Token 就失效了。第二個 Refresh 請求帶著舊 Token 到達 Server,Server 判斷為 「盜用 (Replay Attack)」,直接封鎖該用戶。
正確的實作 (Mutex Lock):
當第一個 401 發生時,開啟鎖 (isRefreshing = true),後續的 401 全部暫停,加入一個 佇列 (Queue) 等待。等第一個換證成功後,再依序重試。
完整 Response 攔截器實作
// 是否正在換證中
let isRefreshing = false;
// 失敗請求的佇列
let failedQueue: any[] = [];
const processQueue = (error: any, token: string | null = null) => {
failedQueue.forEach((prom) => {
if (error) {
prom.reject(error);
} else {
prom.resolve(token);
}
});
failedQueue = [];
};
api.interceptors.response.use(
(response) => response,
async (error: AxiosError) => {
const originalRequest = error.config as AxiosRequestConfig & { _retry?: boolean };
// 如果是 401 且不是「換證API」本身失敗 (避免無窮迴圈)
if (error.response?.status === 401 && !originalRequest._retry) {
if (isRefreshing) {
// 如果正在換證,將請求加入佇列
return new Promise(function (resolve, reject) {
failedQueue.push({
resolve: (token: string) => {
originalRequest.headers!.Authorization = `Bearer ${token}`;
resolve(api(originalRequest));
},
reject: (err: any) => {
reject(err);
},
});
});
}
originalRequest._retry = true;
isRefreshing = true;
try {
// 呼叫後端 Refresh Endpoint (瀏覽器會自動帶 Cookie)
const { data } = await api.post('/auth/refresh');
const newToken = data.accessToken;
setAccessToken(newToken);
// 處理佇列中的請求
processQueue(null, newToken);
// 重試原本失敗的請求
originalRequest.headers!.Authorization = `Bearer ${newToken}`;
return api(originalRequest);
} catch (refreshError) {
// 換證失敗 (Refresh Token 也過期了,或是被後端封鎖)
processQueue(refreshError, null);
// 導向登入頁
window.location.href = '/login';
return Promise.reject(refreshError);
} finally {
isRefreshing = false;
}
}
return Promise.reject(error);
}
);
export default api;
👨💻 筆者點評: 這段 Code 是許多企業級專案的標準配置。 請特別注意
failedQueue的設計。它利用了 Promise 將那些「暫時失敗」的請求resolve函式存起來。一旦拿到新 Token,我們就執行這些resolve,讓那些卡住的請求帶著新 Token 再次出發。
05. React 整合 (App 初始化)
在 React 的根元件,我們需要做初始化的檢查。
// src/context/AuthContext.tsx
import React, { useEffect, useState } from 'react';
import api, { setAccessToken } from '../api/axios';
export const AuthProvider: React.FC = ({ children }) => {
const [isLoading, setIsLoading] = useState(true);
const [user, setUser] = useState(null);
useEffect(() => {
const initAuth = async () => {
try {
// App 啟動時,嘗試用 Cookie 換 Access Token
const { data } = await api.post('/auth/refresh');
setAccessToken(data.accessToken);
// 接著可以拿 Token 去抓使用者資料
// const userRes = await api.get('/users/me');
// setUser(userRes.data);
} catch (error) {
// 沒登入或是 Token 過期,不做事,讓使用者留在 Public 頁面或登入頁
console.log("No valid session");
} finally {
setIsLoading(false);
}
};
initAuth();
}, []);
if (isLoading) return <div>Loading...</div>;
return <AuthContext.Provider value={{ user }}>{children}</AuthContext.Provider>;
};
06. 系列總結:安全性是全端工程
恭喜你!跟隨這四篇文章的腳步,你已經從零打造了一套 金融級別 的身分驗證系統。
我們回顧一下這趟旅程:
- [Ep.01]:理解了為什麼 JWT 適合微服務,但 Session 適合單體,以及為什麼我們選擇「混合架構」。
- [Ep.02]:學會了不依賴
process.env的型別安全配置,並避開了 JWT 的加密誤區。 - [Ep.03]:後端實作了 雙 Token + Redis 黑名單 + Token Rotation,解決了 JWT 無法撤銷的痛點。
- [Ep.04]:前端實作了 Axios 攔截器與請求佇列,完美處理了併發換證的難題。
這套架構雖然複雜,但它兼顧了 安全性 (Security)、效能 (Performance) 與 使用者體驗 (UX)。
希望這份筆記能成為你開發職涯中的一塊基石。未來的架構之路還很長,我們下一個系列見!
同場加映:JWT 的軍火庫與除錯神器
在結束這系列之前,筆者想針對 JWT 的 「加密算法選擇」 與 「除錯工具」 做最後的補充。很多開發者只知道 HS256,但在微服務或高安規場景下,選錯算法可能會導致架構上的麻煩。
1. JWT 簽章算法演化論
JWT 的 Header 中 alg 欄位決定了生死。常見的有以下幾種:
| 算法 | 類型 | 適用場景 | ✅ 優點 | ❌ 缺點 |
|---|---|---|---|---|
| HS256 | 對稱式 (Symmetric) | 單體架構、內部系統 | 速度快,運算成本極低,適合高吞吐量。 | 鑰匙管理難:Server 需共用同一把 Secret,一旦洩漏可隨意偽造 Token。 |
| RS256 | 非對稱式 (Asymmetric) | 微服務、第三方登入 (Auth0) | 安全性高:資源伺服器 (Resource Server) 只需公鑰即可驗證,無需持有私鑰。 | 速度較慢,RSA 運算複雜度較高,Token 長度較長。 |
| ES256 | 非對稱式 (Elliptic Curve) | 行動裝置、IoT、高流量微服務 | 極快且安全:使用橢圓曲線,Key 長度比 RSA 短,運算效能更好。 | 相容性:極舊版系統或舊版 Library 可能不支援。 |
👨💻 筆者建議: 如果你的專案只有一個後端,用 HS256 絕對夠用。但如果你在做微服務,或者需要讓第三方驗證你的 Token,請務必使用 RS256 或 ES256。
2. JWS vs. JWE:別再以為 Base64 是加密
我們常用的 JWT 其實標準名稱是 JWS (JSON Web Signature)。
- JWS:內容是公開的 (Base64Url),只保證「不被竄改」。
- JWE (JSON Web Encryption):內容是加密的,沒有密鑰完全看不到 Payload。
絕大多數 Web 應用使用 JWS 就夠了(前提是 Payload 不放敏感資料)。只有在極少數需要透過 Token 傳遞「身分證字號」或「私密資料」的場景,才需要用到 JWE。
3. 瀏覽器支援程度 (Browser Compatibility)
JWT 本質上就是一個 「很長的字串」。 因此,所有瀏覽器(包含 IE6)都支援 JWT。
瀏覽器的差異僅在於「儲存方式」與「加密 API」:
- HttpOnly Cookie:所有瀏覽器皆支援 (最推薦)。
- LocalStorage:IE8+ 支援。
- Web Crypto API:如果你需要在前端解密 JWE 或驗證簽章(極少見),需要現代瀏覽器 (Chrome, Firefox, Edge, Safari)。
4. 推薦工具:動詞實驗室 JWT Debugger
開發過程中,我們常需要檢查:「這個 Token 到底過期了沒?」或是「裡面的 role 到底有沒有寫進去?」。
這裡推薦一個筆者開發的線上工具:動詞實驗室 JWT Utils
不同於其他複雜的工具,這個工具保持了極簡與直覺:
- 即時解析:貼上 Token,瞬間解碼 Header 與 Payload。
- 隱私安全:純前端解析,不會將你的 Token 傳送到後端伺服器(這一點對測試 Production Token 至關重要)。
將這個連結加入你的書籤,它會是你 Debug 401 錯誤時的好幫手。
驗證系統只是後端架構的冰山一角。如果你對這系列有興趣,或許我們下次可以聊聊「分散式鎖 (Distributed Lock)」或是「高併發下的庫存扣減」?歡迎留言敲碗。

關於作者
Ken Huang
擁有超過 9 年軟體開發經驗的資深工程師,現任 APP 工程師,專精於 Android / iOS 雙平台開發。同時持續拓展後端與雲端技術範疇,致力於朝向全端架構師的領域邁進。
具備完整的專案生命週期實戰經驗:
- 新創開發:主導多個從零到一的新專案規劃、技術選型與開發落地。
- 系統維護與重構:擁有維護與升級 10 年以上大型歷史專案(Legacy Code)的豐富經驗,擅長進行代碼重構與效能優化。
身為知名 PTT 第三方 APP BePTT 的開發者,致力於將 Clean Architecture、Scrum 敏捷開發與現代化軟體工程原則應用於產品中,打造極致的使用者體驗。
verb.tw 是我紀錄技術軌跡與架構思考的基地,內容涵蓋 App 開發實戰、全端技術整合與軟體工程最佳實踐。
系列文章目錄
- 【後端架構系列 01】驗證演化論:從 Session 到 JWT 的技術博弈
- 【後端架構系列 02】解剖 JWT:從原理到 Node.js 實作的避坑指南
- 【後端架構系列 03】雙 Token 機制與 Redis:打造可撤銷的完美驗證
- 【後端架構系列 04】前端生存指南:Axios 攔截器與無感換證 (Silent Refresh) (本文)