JWT 認證最佳實踐:演算法、儲存與吊銷實戰

安全指南

JWT 被濫用的現狀

JWT 幾乎成了「做登入」的預設選項,但我在 code review 裡見過太多誤用:把用戶密碼塞進 payload、用 alg: none 的函式庫版本、把 token 存 localStorage 還奇怪為什麼被 XSS 偷走、token 永遠不失效所以沒法吊銷。

JWT 本身沒問題,問題在「以為它是萬能的 session 替代品」。這篇文章把生產環境裡真正要命的幾個點講清楚。


一、JWT 到底是什麼

JWT 由三部分組成,用 . 分隔:header.payload.signature

  • header:演算法和類型,如 {"alg":"HS256","typ":"JWT"}
  • payload:聲明(claims),如 {"sub":"123","name":"Ada","exp":1700000000}
  • signature:對前兩部分用密鑰簽名,防篡改

關鍵點:payload 只是 Base64Url 編碼,任何人都能解碼看內容。它不是加密。所以千萬別往裡放密碼、身分證號、token 本身。

echo 'eyJzdWIiOiIxMjMifQ' | base64 -d
# → {"sub":"123"}

二、最常見的三個誤區

誤區一:把 JWT 當 session 用

傳統 session 把狀態存在服務端,吊銷一個用戶只要在服務端刪掉 session。JWT 預設是無狀態的——服務端不存它,所以「吊銷」變得困難。如果你需要頻繁吊銷、踢人下線,JWT 並不比 session 簡單,反而更麻煩(見第六節)。

誤區二:payload 塞敏感資料

因為 payload 可讀,放密碼、信箱明文、內部權限位都是洩露風險。只放必要的、非敏感的標識:sub(用戶 ID)、roleexp 等。

誤區三:token 永不过期

不設 exp,或者設了幾年後過期,等於發了一張長期通行證。一旦洩露,攻擊者能一直用。


三、演算法安全:攻擊都從這來

none 演算法攻擊

早期某些函式庫在 alg: none 時會跳過簽名驗證。攻擊者把 header 改成 {"alg":"none"},把 signature 留空,就能偽造任意 payload。

防禦:服務端必須顯式指定允許的演算法白名單,拒絕 none

jwt.verify(token, secret, { algorithms: ["HS256"] });

絕不讓客戶端決定用哪個演算法。

RS256 / HS256 混淆攻擊

這是經典的「演算法混淆」:服務端用 RSA 公鑰驗證(期望 RS256),但攻擊者把 header 改成 HS256,然後用公鑰當 HMAC 的密鑰去簽名。因為公鑰往往是公開可取得的,攻擊者就能偽造合法 token。

防禦:

  • 驗證時同時鎖定演算法和密鑰類型:RS256 用公鑰驗證,HS256 用對稱密鑰。
  • 不要把 RSA 公鑰當 HMAC 密鑰使用。
  • 用成熟函式庫(jsonwebtoken、jose)並顯式傳 algorithms
jwt.verify(token, publicKey, { algorithms: ["RS256"] });

密鑰管理

  • 對稱演算法(HS256)的 secret 要足夠長、隨機、不進程式碼倉庫(用環境變數/密鑰管理)。
  • 非對稱(RS256/ES256)的私鑰只在簽發服務端,公鑰可分發。
  • 支援密鑰輪換(kid 頭標識當前密鑰)。

四、過期與刷新:access + refresh 設計

最佳實踐是把 token 分兩種:

  • access token:短期(如 15 分鐘),用於存取介面,洩露視窗小。
  • refresh token:長期(如 7 天),只用來換新的 access token,存得要更安全。
const accessToken = jwt.sign({ sub: user.id, role }, accessSecret, {
  algorithm: "RS256",
  expiresIn: "15m",
});
const refreshToken = jwt.sign({ sub: user.id, jti: refreshId }, refreshSecret, {
  algorithm: "RS256",
  expiresIn: "7d",
});

刷新介面只接受 refresh token,且每次刷新都發新的 refresh token(輪換),舊的作廢。這樣即使 refresh token 洩露,用一次就被取代。


這是 XSS 與 CSRF 的權衡題。

localStorage 的風險

localStorage 裡的 token 任何 JS 都能讀。一旦有 XSS(哪怕是一個有漏洞的第三方腳本),token 就被偷走發到攻擊者伺服器。而且 localStorage 無法設定 HttpOnly,不可被標記成「只走 HTTP」。

Cookie(HttpOnly + Secure + SameSite)更穩

把 access token 放進 Cookie:

  • HttpOnly:JS 讀不到,擋住 XSS 竊取。
  • Secure:只走 HTTPS。
  • SameSite=Strict/Lax:擋住大多數 CSRF。

代價是要處理 CSRF(見第七節)。對大多數 Web 應用,這是比 localStorage 更安全的預設選擇。

res.setHeader("Set-Cookie", [
  `access=${accessToken}; HttpOnly; Secure; SameSite=Lax; Path=/; Max-Age=900`,
]);

折中方案

  • SPA + 後端同域:用 HttpOnly Cookie 存 access,前端透過 fetch 自動帶 cookie。
  • 行動端 / 第三方呼叫:用 Authorization 頭 + 短期 access token,refresh 走安全儲存。

六、吊銷與黑名單:jti

access token 短期 + refresh 輪換,已經能控制大部分風險。但「立刻踢人下線」怎麼辦?

給每個 token 一個唯一 jti(JWT ID),維護一個「吊銷列表」(Redis 黑名單,帶 TTL 等於 token 剩餘壽命):

await redis.set(`blacklist:${jti}`, "1", "EX", remainingSeconds);
if (await redis.get(`blacklist:${jti}`)) throw new Error("revoked");

這樣不需要在服務端存所有 token,只存「被吊銷的」,代價極小。注意:黑名單本身只覆蓋「需要主動吊銷」的場景(封號、改密碼),正常過期仍靠 exp


七、CSRF 防護與 SameSite

用 Cookie 就必須考慮 CSRF:攻擊者誘導用戶瀏覽器向你的域名發請求,瀏覽器自動帶上 Cookie,於是以用戶身分執行操作。

  • SameSite=Lax/Strict:現代瀏覽器預設就能擋掉大多數跨站請求帶 Cookie。優先用 SameSite=Lax
  • CSRF Token:對敏感寫操作,要求請求帶服務端下發的隨機 token(double-submit)。
  • 重要寫操作要求重新認證:改密碼、轉帳等,除了 Cookie 還要二次驗證。
app.post("/api/transfer", requireReauth, handler);

八、實戰:Node/Express 中介軟體

一個最小但安全的校驗中介軟體:

import jwt from "jsonwebtoken";

export function authGuard(publicKey: string) {
  return (req, res, next) => {
    const token = req.cookies?.access; // HttpOnly Cookie
    if (!token) return res.status(401).json({ error: "no token" });

    let payload;
    try {
      payload = jwt.verify(token, publicKey, { algorithms: ["RS256"] });
    } catch {
      return res.status(401).json({ error: "invalid token" });
    }

    if (blacklist.has(payload.jti)) return res.status(401).json({ error: "revoked" });

    req.user = { id: payload.sub, role: payload.role };
    next();
  };
}

配套的刷新介面:

app.post("/api/refresh", async (req, res) => {
  const rt = req.cookies?.refresh;
  if (!rt) return res.status(401).json({ error: "no refresh" });
  const payload = jwt.verify(rt, refreshPublicKey, { algorithms: ["RS256"] });
  if (blacklist.has(payload.jti)) return res.status(401).json({ error: "revoked" });

  blacklist.add(payload.jti);
  const newAccess = issueAccess(payload.sub);
  const newRefresh = issueRefresh(payload.sub); // 新 jti
  res.setHeader("Set-Cookie", [accessCookie(newAccess), refreshCookie(newRefresh)]);
  res.json({ ok: true });
});

常見問題

Q1:JWT 和 session 哪個好?

看需求。需要服務端集中控制、頻繁踢人、存大量會話狀態——session 更合適。需要無狀態、跨服務、行動端友好——JWT 更合適。別因為「流行」就無腦選 JWT。

Q2:access token 過期時間設多長?

Web 應用 5~15 分鐘較穩;配合 refresh 輪換。太長洩露視窗大,太短刷新頻繁影響體驗。

Q3:為什麼我的 JWT 函式庫報「invalid algorithm」?

基本是服務端期望 RS256 但收到了 HS256,或反過來——演算法沒鎖死。務必 algorithms: [...] 顯式聲明,且 RS256 用公鑰驗證。

Q4:前端怎麼拿用戶資訊?

不要解碼 JWT 拿敏感資料(payload 可讀但不可信,且可能過期)。讓用戶介面(/api/me)返回權威的用戶資訊,前端只存「用戶 ID」這類非敏感標識。

Q5:JWT 能加密嗎?

能,用 JWE(JSON Web Encryption)可以加密 payload。但多數場景只需要簽名(防篡改)即可,加密會增加複雜度。敏感資料就別放進去,比加密更省事。


工具推薦

調試 token、看它到底裝了什麼時,這些 ToolsKu 工具順手:

  • JWT 解碼 — 粘貼 token 看 header/payload(不校驗簽名,純查看)
  • JWT 生成器 — 自己填 header/payload/密鑰生成測試 token
  • HMAC — 手工驗證 HS256 簽名是否對得上

JWT 不是 session 的替代品,而是另一種權衡:用「無狀態、可分發」換來了「吊銷麻煩、payload 可讀」。想清楚你換來了什麼、又失去了什麼,才算真正用好了它。

本站提供瀏覽器本地工具,免註冊即可試用 →

#JWT#认证#安全#Token#鉴权