本文 AI 產出,尚未審核
ExpressJS (TypeScript) – JWT 與 Auth 驗證
Access Token vs Refresh Token
簡介
在現代的單頁應用(SPA)與行動 App 中,JSON Web Token(JWT) 已成為最常見的驗證機制。它讓前端可以在每一次 API 呼叫時,帶上一段簽名好的字串,伺服器只要驗證簽名與過期時間,就能快速決定使用者是否有權限。
然而,單純使用 Access Token(存取令牌)會有兩大挑戰:
- 安全性:若 Access Token 的有效期限過長,一旦被盜,攻擊者可以持續存取資源。
- 使用者體驗:若有效期限過短,使用者必須頻繁重新登入,影響使用感受。
為了解決這兩個矛盾,業界普遍採用 Refresh Token(重新整理令牌)配合 Access Token 的雙令牌機制。本文將深入說明兩者的差異、實作方式,以及在 ExpressJS + TypeScript 專案中如何安全、有效地運用它們。
核心概念
1. Access Token(存取令牌)
- 用途:在每一次受保護的 API 請求中攜帶,用來告訴伺服器「此請求的使用者已通過驗證」
- 內容:通常只放置
sub(使用者 ID)、iat(發行時間)、exp(過期時間)以及少量的權限(scope)資訊 - 有效期限:建議 5~15 分鐘,足以降低被盜後的危害範圍
2. Refresh Token(重新整理令牌)
- 用途:在 Access Token 過期後,向認證伺服器換取新的 Access Token,避免使用者再次登入
- 內容:可以只放置一個唯一的 UUID,或是同樣使用 JWT 但 不包含敏感權限,且有效期限較長(天或月)
- 存放位置:絕對不要放在
localStorage,建議使用 HttpOnly、Secure 的 Cookie,或在行動端使用安全儲存(Keychain、Keystore)
3. 雙令牌流程概覽
sequenceDiagram
participant Client
participant API
participant AuthServer
Client->>API: 帶 Access Token (Authorization: Bearer)
API->>AuthServer: 驗證 Access Token
AuthServer-->>API: 驗證成功 / 失敗
API-->>Client: 回傳資源或 401
alt Access Token 過期
Client->>AuthServer: 帶 Refresh Token (Cookie)
AuthServer->>AuthServer: 驗證 Refresh Token
AuthServer-->>Client: 新的 Access Token + (可選) 新的 Refresh Token
Client->>API: 再次帶新 Access Token
end
4. 為什麼要分離?
| 觀點 | Access Token | Refresh Token |
|---|---|---|
| 有效期限 | 短(分鐘) | 長(天~月) |
| 存放位置 | 前端 Authorization Header |
HttpOnly Cookie / Secure Storage |
| 洩漏後的危害 | 受限於短時間 | 需要額外防護機制(如撤銷、輪換) |
| 使用頻率 | 每次 API 呼叫 | 只在 Access Token 失效時使用 |
程式碼範例
以下示範在 ExpressJS + TypeScript 中,如何產生、驗證 Access Token 與 Refresh Token,並實作「刷新」機制。
⚠️ 範例僅示意,實務上仍需加上 HTTPS、CORS、Rate Limiting 等安全設定。
1️⃣ 建立 JWT 工具函式
// src/utils/jwt.ts
import jwt from 'jsonwebtoken';
import { Request, Response, NextFunction } from 'express';
// 讀取環境變數(建議使用 .env)
const ACCESS_SECRET = process.env.ACCESS_TOKEN_SECRET!;
const REFRESH_SECRET = process.env.REFRESH_TOKEN_SECRET!;
// Access Token: 5 分鐘
export const signAccessToken = (payload: object) => {
return jwt.sign(payload, ACCESS_SECRET, { expiresIn: '5m' });
};
// Refresh Token: 30 天
export const signRefreshToken = (payload: object) => {
return jwt.sign(payload, REFRESH_SECRET, { expiresIn: '30d' });
};
// 驗證 Access Token (middleware)
export const verifyAccessToken = (
req: Request,
res: Response,
next: NextFunction,
) => {
const authHeader = req.headers['authorization'];
const token = authHeader?.split(' ')[1];
if (!token) return res.sendStatus(401); // 沒有 token
jwt.verify(token, ACCESS_SECRET, (err, decoded) => {
if (err) return res.sendStatus(403); // token 無效或過期
// 把使用者資訊掛在 request 上,供後續 handler 使用
(req as any).user = decoded;
next();
});
};
2️⃣ 登入與發放雙令牌
// src/controllers/auth.controller.ts
import { Request, Response } from 'express';
import { signAccessToken, signRefreshToken } from '../utils/jwt';
import { UserModel } from '../models/user.model'; // 假設已有 Mongoose model
export const login = async (req: Request, res: Response) => {
const { email, password } = req.body;
// 1. 驗證帳號密碼 (此處省略 bcrypt 比對)
const user = await UserModel.findOne({ email });
if (!user) return res.status(401).json({ message: 'Invalid credentials' });
// 2. 產生 Access Token & Refresh Token
const accessToken = signAccessToken({ sub: user.id, role: user.role });
const refreshToken = signRefreshToken({ sub: user.id });
// 3. 將 Refresh Token 存入資料庫(可選,用於撤銷)
await user.updateOne({ $push: { refreshTokens: refreshToken } });
// 4. 把 Refresh Token 寫入 HttpOnly Cookie
res.cookie('refreshToken', refreshToken, {
httpOnly: true,
secure: true, // 只在 HTTPS 下傳送
sameSite: 'strict',
maxAge: 30 * 24 * 60 * 60 * 1000, // 30 天
});
// 5. 回傳 Access Token
return res.json({ accessToken });
};
3️⃣ 刷新 Access Token
// src/controllers/token.controller.ts
import { Request, Response } from 'express';
import jwt from 'jsonwebtoken';
import { signAccessToken, signRefreshToken } from '../utils/jwt';
import { UserModel } from '../models/user.model';
const REFRESH_SECRET = process.env.REFRESH_TOKEN_SECRET!;
export const refreshToken = async (req: Request, res: Response) => {
const token = req.cookies.refreshToken;
if (!token) return res.sendStatus(401);
// 1. 驗證 Refresh Token
jwt.verify(token, REFRESH_SECRET, async (err, decoded: any) => {
if (err) return res.sendStatus(403); // token 無效或過期
const user = await UserModel.findById(decoded.sub);
if (!user) return res.sendStatus(404);
// 2. (可選) 檢查 token 是否仍在使用者的白名單
if (!user.refreshTokens.includes(token)) return res.sendStatus(403);
// 3. 產生新的 Access Token
const newAccess = signAccessToken({ sub: user.id, role: user.role });
// 4. (可選)旋轉 Refresh Token:舊的作廢,發新 token
const newRefresh = signRefreshToken({ sub: user.id });
await user.updateOne({
$pull: { refreshTokens: token },
$push: { refreshTokens: newRefresh },
});
res.cookie('refreshToken', newRefresh, {
httpOnly: true,
secure: true,
sameSite: 'strict',
maxAge: 30 * 24 * 60 * 60 * 1000,
});
// 5. 回傳新 Access Token
return res.json({ accessToken: newAccess });
});
};
4️⃣ 保護受限資源
// src/routes/protected.ts
import { Router } from 'express';
import { verifyAccessToken } from '../utils/jwt';
const router = Router();
router.get('/profile', verifyAccessToken, async (req, res) => {
const user = (req as any).user; // 由 middleware 注入
// 這裡可以根據 user.sub 取得使用者完整資料
res.json({ message: `Hello ${user.sub}, this is your profile.` });
});
export default router;
5️⃣ 登出與撤銷 Refresh Token
// src/controllers/auth.controller.ts (續)
export const logout = async (req: Request, res: Response) => {
const token = req.cookies.refreshToken;
if (token) {
// 從 DB 中移除(或加入黑名單)
const decoded = jwt.decode(token) as { sub: string };
await UserModel.updateOne(
{ _id: decoded.sub },
{ $pull: { refreshTokens: token } },
);
}
// 清除 Cookie
res.clearCookie('refreshToken', {
httpOnly: true,
secure: true,
sameSite: 'strict',
});
return res.sendStatus(204);
};
常見陷阱與最佳實踐
| 陷阱 | 為什麼會發生 | 解決方案 |
|---|---|---|
把 Refresh Token 放在 localStorage |
前端腳本可直接讀取,XSS 攻擊時易被竊取 | 使用 HttpOnly、Secure Cookie,或行動端的安全儲存 |
| Access Token 有過長有效期限 | 攻擊者取得 token 後可長時間濫用 | 設定 5~15 分鐘,並配合 Refresh Token 重新取得 |
| 未在伺服器端撤銷失效的 Refresh Token | 使用者登出或密碼變更後,舊 token 仍能換取新 Access Token | 在資料庫或快取中 維護白名單 / 黑名單,登出時刪除或標記失效 |
| 在同一個端點同時回傳 Access & Refresh Token,且未加密傳輸 | 攻擊者可同時取得兩種 token,危害更大 | 確保 HTTPS,且 Refresh Token 僅以 Cookie 形式回傳 |
| 把過期時間寫死在程式碼 | 隨著需求變化難以調整,且環境不同 | 把時間、密鑰等參數放在 環境變數,並在部署時調整 |
推薦的安全措施
- HTTPS:所有 token 的傳輸必須加密。
- CORS:只允許可信的前端來源。
- Rate Limiting:對
/login、/refresh端點做頻率限制,防止暴力攻擊。 - Token 旋轉(Rotation):每次使用 Refresh Token 換取 Access Token 時,同時發新 Refresh Token,舊 token 立即失效。
- 使用
jti(JWT ID):在 Refresh Token 中加入唯一 ID,方便在資料庫中追蹤與撤銷。
實際應用場景
| 場景 | 為什麼需要雙令牌 | 實作要點 |
|---|---|---|
| 單頁應用(React / Vue) | 前端持續呼叫 API,若使用長期 Access Token,安全風險高 | 使用 HttpOnly Cookie 存 Refresh Token,前端只保留短期 Access Token |
| 行動 App(React Native / Flutter) | 手機可能在不安全的網路環境,且使用者期望長時間登入 | 使用安全儲存(Keychain、Keystore)保存 Refresh Token,並在背景自動刷新 Access Token |
| 微服務架構 | 各服務之間需要驗證使用者身份,且要避免每個服務都保存使用者密碼 | 在 API Gateway 只驗證 Access Token,若需要延長可向認證服務請求新的 Access Token |
| 多設備登入 | 使用者可能同時在手機、平板、電腦登入,需要分別撤銷某一設備的權限 | 為每個設備產生獨立的 Refresh Token(含 jti),在資料庫中記錄設備資訊,撤銷時只刪除該筆 token |
總結
- Access Token 只負責「快速驗證」每一次 API 請求,有效期限短、放在 Authorization Header。
- Refresh Token 則是「長期授權」的後盾,有效期限長、必須放在 HttpOnly Cookie 或安全儲存,並在伺服器端做好撤銷與旋轉機制。
- 在 ExpressJS + TypeScript 中,我們可以透過
jsonwebtoken搭配中介軟體(middleware)快速產生與驗證 token,同時利用 Cookie 與 資料庫白名單 保護 Refresh Token。 - 遵守 HTTPS、CORS、Rate Limiting,以及 Token 旋轉、撤銷機制,即可大幅降低 JWT 被盜用的風險,同時提供使用者流暢的登入體驗。
藉由本篇的概念說明與完整範例,你現在已經掌握了 Access Token vs Refresh Token 的設計原則,並能在自己的 ExpressJS 專案中安全、有效地實作 JWT 驗證。祝開發順利,打造更安全的 Web 服務!