動詞實驗室 Logo動詞實驗室

【後端架構系列 02】解剖 JWT:從原理到 Node.js 實作的避坑指南

發布於 閱讀時間 10 分鐘

01. 前言:不要當 Copy-Paste 工程師

在上一篇 【後端架構系列 01】驗證演化論 中,我們釐清了 Session 與 JWT 的架構差異。今天,我們要捲起袖子寫 Code 了。

很多新手的起手式是去 StackOverflow 複製一段 jwt.sign() 的代碼,能跑就不管了。但你知道嗎?JWT 其實是一個裸奔的資料格式。 如果你不明就裡地把使用者的 Email、甚至是權限設定直接塞進去,你其實正在網路上廣播你的資料庫結構。

這篇文章,我們要深入 JWT 的肌肉與骨骼(Structure),理解它的靈魂(Signature),最後用 Node.js + TypeScript 打造一個安全、可維護的驗證模組。

02. JWT 的解剖學:它不是加密,是編碼

JWT (JSON Web Token) 的外觀通常是這樣的:aaaaa.bbbbb.ccccc。 這三個部分由 . 分隔,分別是:

  1. Header (標頭):我是誰?用什麼演算法?
  2. Payload (內容):我要傳遞什麼資料?
  3. Signature (簽章):證明我沒被改過。

致命誤區:Base64Url 不是加密!

請看下面這個 Payload:

{
  "sub": "1234567890",
  "name": "John Doe",
  "admin": true
}

經過 Base64Url 編碼後,它會變成一串看似亂碼的字串。但請注意,這過程完全可逆,不需要任何金鑰。 任何人拿到這串字串,丟進 Base64 解碼器,都能看到 admin: true

👨‍💻 筆者警告: 絕對不要 在 Payload 中放入敏感資訊(如密碼、身分證號、信用卡號)。 JWT 的設計目的是「防篡改 (Integrity)」,而不是「防竊聽 (Confidentiality)」。如果你需要傳輸敏感資料,請使用 JWE (JSON Web Encryption) 或走 HTTPS 加密通道。

03. 簽章的數學魔法:HMAC 的力量

JWT 為什麼安全?關鍵在於第三段的 Signature

它的產生公式如下:

$$Signature = HMACSHA256( base64Url(Header) + "." + base64Url(Payload), your_secret_key )$$

運作原理

  1. Server 手上有一把 secret_key(例如:"my-super-secret")。
  2. 當 Server 發 Token 給 User 時,它把 Header 和 Payload 接起來,用 secret_key 攪拌一下(雜湊),算出簽章。
  3. User 下次帶 Token 來時,Server 把 Header 和 Payload 再拿出來,用同樣的 secret_key 再算一次。
  4. 比對: 如果算出來的結果跟 Token 第三段一樣,代表資料沒被改過。

如果駭客把 Payload 裡的 admin: false 改成 true,但他不知道 secret_key,他就無法算出對應的新簽章。Server 一比對就會發現:「嘿!你的簽章跟內容對不上,滾!」

04. 實戰工坊:Node.js + TypeScript 實作

我們不只要會用 jsonwebtoken,還要封裝成符合企業級開發規範的工具。

環境準備

npm install express jsonwebtoken dotenv
npm install -D typescript @types/express @types/jsonwebtoken @types/node

步驟一:定義型別與設定檔 (Strong Typing)

不要在代碼裡到處寫 process.env.JWT_SECRET,這很難維護。

// src/config/index.ts
import dotenv from 'dotenv';
dotenv.config();

export const config = {
  jwt: {
    secret: process.env.JWT_SECRET || 'default_secret_please_change_me',
    accessExpiration: '15m', // Access Token 短效期
    refreshExpiration: '7d', // Refresh Token 長效期
  },
};

步驟二:封裝 JWT Utils (核心邏輯)

我們會建立一個 jwt.utils.ts,這裡應用了 單一職責原則 (SRP),把簽發與驗證邏輯集中管理。

// src/utils/jwt.utils.ts
import jwt, { SignOptions } from 'jsonwebtoken';
import { config } from '../config';

// 定義我們 Token 裡具體要存什麼,這能讓 TypeScript 幫我們檢查
export interface TokenPayload {
  userId: string;
  role: string;
  email: string;
}

/**
 * 簽發 Access Token
 * @param payload - 使用者資訊
 */
export const signAccessToken = (payload: TokenPayload): string => {
  const signInOptions: SignOptions = {
    expiresIn: config.jwt.accessExpiration,
    algorithm: 'HS256' // 明確指定演算法,防止 None Algorithm 攻擊
  };
  
  return jwt.sign(payload, config.jwt.secret, signInOptions);
};

/**
 * 驗證 Token
 * @param token - 從 Header 拿到的字串
 */
export const verifyToken = (token: string): TokenPayload | null => {
  try {
    const decoded = jwt.verify(token, config.jwt.secret) as TokenPayload;
    return decoded;
  } catch (error) {
    // 這裡可以細分錯誤類型:TokenExpiredError 或 JsonWebTokenError
    return null;
  }
};

👨‍💻 筆者點評: 注意我在 jwt.sign 時明確指定了 algorithm: 'HS256'。 歷史上有一個著名的漏洞叫 "None Algorithm Attack"。早期有些 Library 允許 Header 設定 {"alg": "none"},Server 看到這個設定就會跳過簽章驗證。駭客只要把 Token Header 改成 None,就可以偽造任意身分。雖然現代 Library 多已修復,但明確指定演算法是個好習慣。

步驟三:製作 Auth Middleware (守門員)

接下來我們要把它掛載到 Express 的路由上。

// src/middlewares/auth.middleware.ts
import { Request, Response, NextFunction } from 'express';
import { verifyToken, TokenPayload } from '../utils/jwt.utils';

// 擴充 Express Request 型別,讓後面的 Controller 可以直接用 req.user
declare global {
  namespace Express {
    interface Request {
      user?: TokenPayload;
    }
  }
}

export const authenticate = (req: Request, res: Response, next: NextFunction) => {
  // 1. 從 Header 取出 Token
  // 格式通常是: Authorization: Bearer <token>
  const authHeader = req.headers.authorization;

  if (!authHeader?.startsWith('Bearer ')) {
    return res.status(401).json({ message: '未提供 Token 或格式錯誤' });
  }

  const token = authHeader.split(' ')[1];

  // 2. 驗證 Token
  const payload = verifyToken(token);

  if (!payload) {
    return res.status(403).json({ message: 'Token 無效或已過期' });
  }

  // 3. 驗證成功,將資料掛載到 req 物件
  req.user = payload;
  
  // 4. 放行
  next();
};

05. 安全性補完計畫

有了上面的代碼,你的 JWT 系統已經比 80% 的網上教學更安全了。但為了達到 Production Ready,我們還需要注意以下幾點:

1. Secret Key 的強度

你的 JWT_SECRET 就像你的銀行密碼。不要用 secret123 這種爛密碼。請使用 openssl rand -base64 32 生成一個高強度的隨機字串。如果 Key 被暴力破解,你的整個驗證系統就崩塌了。

2. Token 的儲存位置

這是一個千古難題:LocalStorage vs Cookie

  • LocalStorage: 容易被 XSS (跨站腳本攻擊) 竊取。如果你的網站有漏洞讓駭客能執行 JS,他們就能 localStorage.getItem('token') 把你的 Token 偷走。
  • Cookie (HttpOnly): JS 讀不到,防 XSS,但容易被 CSRF (跨站請求偽造) 攻擊。

筆者推薦方案: 對於一般專案,Access Token 存記憶體 (JS 變數),Refresh Token 存 HttpOnly Cookie 是目前公認最安全的折衷方案。這部分我們將在下一篇詳細實作。

06. 下集預告:Token 的輪迴與重生

現在我們能簽發 Token 了,但如果 Token 過期了怎麼辦?讓使用者重登嗎?體驗太差了。

如果我們把 Token 有效期設長一點(例如 30 天),那如果使用者的手機丟了,我們怎麼讓他「強制登出」?(記得嗎,JWT 是 Stateless 的)。

這就是 Refresh TokenRedis 登場的時候了。

在下一篇 【Ep.3:雙 Token 機制與 Redis 黑名單實戰】 中,我們將構建一個完整的 Token 生命週期管理系統,實現「無感換證」與「一鍵踢人」的高級功能。


程式碼可以複製,但觀念必須內化。試著自己把 verifyToken 的邏輯寫一遍,你會發現更多細節。


Ken Huang

關於作者

Ken Huang

擁有超過 9 年軟體開發經驗的資深工程師,現任 APP 工程師,專精於 Android / iOS 雙平台開發。同時持續拓展後端與雲端技術範疇,致力於朝向全端架構師的領域邁進。

具備完整的專案生命週期實戰經驗:

  • 新創開發:主導多個從零到一的新專案規劃、技術選型與開發落地。
  • 系統維護與重構:擁有維護與升級 10 年以上大型歷史專案(Legacy Code)的豐富經驗,擅長進行代碼重構與效能優化。

身為知名 PTT 第三方 APP BePTT 的開發者,致力於將 Clean Architecture、Scrum 敏捷開發與現代化軟體工程原則應用於產品中,打造極致的使用者體驗。

verb.tw 是我紀錄技術軌跡與架構思考的基地,內容涵蓋 App 開發實戰、全端技術整合與軟體工程最佳實踐。

Senior App EngineerAndroid/iOS ExpertBePTT CreatorSystem Architecture