【後端架構系列 03】雙 Token 機制與 Redis:打造可撤銷的完美驗證
01. 前言:魚與熊掌可以兼得
在 Ep.01 中,我們提到 JWT 的最大痛點是 「無法撤銷」。一旦你把有效期設為 30 天,這 30 天內使用者的帳號就像無人管理的後門,即使改了密碼也關不上。
但如果把有效期設為 15 分鐘?使用者一天要登入 100 次,這體驗簡直是災難。
解法就是「雙 Token 機制 (Dual Token Scheme)」。
這套架構的核心思想是:把「驗證身分 (Access)」和「維持登入 (Refresh)」職責分離。這也是 Google、Facebook 等巨頭目前採用的主流方案。
02. 架構設計:Access vs. Refresh
我們需要兩把鑰匙:
-
Access Token (短效期,約 15 分鐘)
- 性質:Stateless (JWT)。
- 用途:存取 API 資源 (GET /users, POST /orders)。
- 儲存:前端記憶體 (Memory) 或變數中。
- 特點:因為效期短,就算被偷,駭客也只能用 15 分鐘。Server 驗證時不查資料庫,效能極高。
-
Refresh Token (長效期,約 7 天)
- 性質:Stateful (存於 Redis)。
- 用途:唯一用途是換取新的 Access Token。
- 儲存:HttpOnly Cookie (防止 XSS 竊取)。
- 特點:每次換發時,Server 會去 Redis 檢查這個 Token 是否還有效。如果使用者被封鎖或登出,Redis 紀錄被刪除,換發就會失敗。
03. 環境準備:Redis 登場
我們需要 Redis 來儲存 Refresh Token 的白名單(或是黑名單)。這裡採用 白名單策略:只有在 Redis 裡找得到的 Refresh Token 才是合法的。
npm install ioredis cookie-parser
npm install -D @types/cookie-parser
設定 Redis Client
// src/config/redis.ts
import Redis from 'ioredis';
const redis = new Redis({
host: process.env.REDIS_HOST || 'localhost',
port: Number(process.env.REDIS_PORT) || 6379,
password: process.env.REDIS_PASSWORD,
});
redis.on('error', (err) => console.error('Redis Client Error', err));
redis.on('connect', () => console.log('Redis Client Connected'));
export default redis;
04. 核心實作:登入、換發、登出
這段程式碼展示了如何將 Redis 整合進 JWT 流程中。
A. 工具函式更新
我們需要新的 helper function 來處理 Refresh Token。
// src/utils/jwt.utils.ts (新增)
import redis from '../config/redis';
// ... 原有的 signAccessToken ...
export const signRefreshToken = async (userId: string): Promise<string> => {
// 1. 簽發 JWT (Payload 可以只放 userId)
const token = jwt.sign({ userId }, config.jwt.secret, {
expiresIn: config.jwt.refreshExpiration
});
// 2. 存入 Redis (Key: refreshToken, Value: userId, TTL: 7天)
// 為了支援多裝置登入,可以將 Key 設計為 `refresh_token:${userId}:${deviceId}`
// 這裡簡化為直接存 token
await redis.set(`refresh_token:${token}`, userId, 'EX', 7 * 24 * 60 * 60);
return token;
};
export const verifyRefreshToken = async (token: string): Promise<string | null> => {
try {
// 1. 驗證簽章
const decoded = jwt.verify(token, config.jwt.secret) as TokenPayload;
// 2. 檢查 Redis 白名單 (這是關鍵!)
const storedUserId = await redis.get(`refresh_token:${token}`);
if (!storedUserId || storedUserId !== decoded.userId) {
return null; // Token 被撤銷或不匹配
}
return storedUserId;
} catch (error) {
return null;
}
};
B. Auth Controller (業務邏輯)
這是整個機制的靈魂。特別注意 refresh 和 logout 的邏輯。
// src/controllers/auth.controller.ts
import { Request, Response } from 'express';
import { signAccessToken, signRefreshToken, verifyRefreshToken } from '../utils/jwt.utils';
import redis from '../config/redis';
export const login = async (req: Request, res: Response) => {
// ... (省略驗證帳號密碼邏輯) ...
const userId = "user_123";
// 1. 產出雙 Token
const accessToken = signAccessToken({ userId, role: 'user', email: '[email protected]' });
const refreshToken = await signRefreshToken(userId);
// 2. 將 Refresh Token 寫入 HttpOnly Cookie
res.cookie('refreshToken', refreshToken, {
httpOnly: true, // JS 讀不到,防 XSS
secure: process.env.NODE_ENV === 'production', // HTTPS only
sameSite: 'strict', // 防 CSRF
maxAge: 7 * 24 * 60 * 60 * 1000 // 7 days
});
// 3. Access Token 回傳給前端 (存記憶體)
res.json({ accessToken });
};
export const refresh = async (req: Request, res: Response) => {
// 1. 從 Cookie 拿 Token
const refreshToken = req.cookies.refreshToken;
if (!refreshToken) return res.status(401).json({ message: '請重新登入' });
// 2. 驗證 Token + Redis 檢查
const userId = await verifyRefreshToken(refreshToken);
if (!userId) {
// ⚠️ 安全警報:這可能是重放攻擊
// 進階策略:如果是已知的惡意 Token,可以清除該用戶所有 Token
res.clearCookie('refreshToken');
return res.status(403).json({ message: 'Token 無效或已過期' });
}
// 3. Token Rotation (旋轉機制) - 安全性大升級
// 舊的 Refresh Token 用過即丟,防止被攔截重複使用
await redis.del(`refresh_token:${refreshToken}`);
const newAccessToken = signAccessToken({ userId, role: 'user', email: '...' });
const newRefreshToken = await signRefreshToken(userId);
// 4. 發新的 Cookie
res.cookie('refreshToken', newRefreshToken, {
httpOnly: true,
secure: process.env.NODE_ENV === 'production',
sameSite: 'strict',
maxAge: 7 * 24 * 60 * 60 * 1000
});
res.json({ accessToken: newAccessToken });
};
export const logout = async (req: Request, res: Response) => {
const refreshToken = req.cookies.refreshToken;
if (refreshToken) {
// 1. 從 Redis 移除,讓它即刻失效
await redis.del(`refresh_token:${refreshToken}`);
}
// 2. 清除 Cookie
res.clearCookie('refreshToken');
res.status(200).json({ message: '登出成功' });
};
05. 筆者觀點:Token Rotation 的必要性
你可能會問:「為什麼在 refresh 接口,我不只發新的 Access Token,連 Refresh Token 都要換新的(Rotation)?」
👨💻 筆者深度解析: 想像一下,如果你的 Refresh Token 有效期是 7 天,且從不更換。 一旦駭客偷到了這個 Token(例如透過惡意瀏覽器插件),他在這 7 天內可以無限次申請 Access Token,完全接管你的帳號。
Token Rotation (旋轉) 是為了縮小攻擊視窗。 每次換發時,舊的 Refresh Token 立刻作廢。如果駭客偷了舊的去用,會發現失效。如果駭客偷了新的,但他用了一次後,合法使用者也要用,這時會有兩個人拿著同一個「家族」的 Token 來換發。
進階防禦: Server 偵測到「重複使用舊 Token」的行為時,視為帳號被盜,立即撤銷該 User ID 下所有的 Refresh Token,強迫所有裝置重登。這才是銀行等級的安全防護。
06. 結論:架構的最後一塊拼圖
至此,我們的後端驗證架構已經非常完整:
- Stateless 的高效:API 請求只需驗證簽章,不查庫。
- Stateful 的安全:Redis 把守 Refresh 關口,隨時能踢人。
- HttpOnly 的防護:Cookie 保護 Refresh Token 不被 JS 竊取。
但這一切都需要前端配合。前端要怎麼處理 Access Token 過期時的 401 錯誤?怎麼在使用者無感的情況下發送 Refresh 請求?
在系列文的最終章 【Ep.04:前端生存指南 - Axios 攔截器與無感換證】,我們將切換視角,從前端工程師的角度,寫出優雅的 API Client 封裝。
Redis 雖然快,但也是成本。如果你的專案只是小型內部系統,用資料庫 (SQL) 替代 Redis 存 Refresh Token 也是完全可以接受的權衡。

關於作者
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)