本文 AI 產出,尚未審核

ExpressJS (TypeScript) – JWT 與 Auth 驗證

Access Token vs Refresh Token


簡介

在現代的單頁應用(SPA)與行動 App 中,JSON Web Token(JWT) 已成為最常見的驗證機制。它讓前端可以在每一次 API 呼叫時,帶上一段簽名好的字串,伺服器只要驗證簽名與過期時間,就能快速決定使用者是否有權限。

然而,單純使用 Access Token(存取令牌)會有兩大挑戰:

  1. 安全性:若 Access Token 的有效期限過長,一旦被盜,攻擊者可以持續存取資源。
  2. 使用者體驗:若有效期限過短,使用者必須頻繁重新登入,影響使用感受。

為了解決這兩個矛盾,業界普遍採用 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 形式回傳
把過期時間寫死在程式碼 隨著需求變化難以調整,且環境不同 把時間、密鑰等參數放在 環境變數,並在部署時調整

推薦的安全措施

  1. HTTPS:所有 token 的傳輸必須加密。
  2. CORS:只允許可信的前端來源。
  3. Rate Limiting:對 /login/refresh 端點做頻率限制,防止暴力攻擊。
  4. Token 旋轉(Rotation):每次使用 Refresh Token 換取 Access Token 時,同時發新 Refresh Token,舊 token 立即失效。
  5. 使用 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 服務!