跳至內容

JWT 常見陷阱與最佳實務

JWT 的五個常見地雷——alg=none、HMAC 密鑰過弱、缺少 exp、盲信 kid、混用 JWS 與 JWE——以及在 2026 年該如何各個擊破。

JWT——JSON Web Token——是 2026 年 HTTP API 的預設權杖格式。規格很 小(RFC 7519)、函式庫到處都有,一個權杖不過是用點號連起來的三段 base64url。看似簡單,正是問題所在:本文所列的每一個地雷,都曾在你聽 過名字的公司上線過,而且往往不只一次。只要知道要找什麼,防禦其實 很直接。

本文走過五個反覆出現的失敗模式——alg=none、HMAC 密鑰長度不足、 缺少 exp、把 kid 當路徑信任、混淆 JWS 與 JWE——並在結尾給出新 系統的演算法選擇建議。

alg=none——最原始的罪

2015 年 Tim McLean 公開了針對早期 JWT 函式庫的 alg=none 攻擊。JWT header 宣告了簽章演算法,而某一類函式庫就無條件地相信這個宣告。攻擊 者送出 header 為 {"alg":"none"}、signature 段為空的權杖,函式庫 便乖乖跳過驗證、把它視為合法。Auth0、node-jsonwebtoken、pyjwt 及一 長串分支都中過同一個 bug 的不同版本。

RFC 8725(JWT 最佳實務,2020)把這條列為第一項對策:該由驗證端 而非權杖本身決定可接受的演算法。具體做法是每個呼叫端都顯式傳入 允許清單:

// vulnerable: trusts the alg header
const payload = jwt.verify(token, key);

// fixed: pin the algorithm
const payload = jwt.verify(token, key, { algorithms: ["RS256"] });

同一條規則也適用於演算法混淆攻擊:把以 HS256 簽的權杖丟給原本預期 RS256 的驗證端。若驗證端「依 header 的 alg 決定使用的程序」,就會把 公鑰當作 HMAC 密鑰雜湊——而任何人(公鑰依定義是公開的)都能藉此偽造 權杖。請把演算法寫死。

HMAC 密鑰過弱

HS256 是對稱的:同一把密鑰簽署、同一把密鑰驗證。RFC 7518 §3.2 寫 得很清楚——密鑰長度至少要等於 HMAC 輸出長度,也就是 HS256 至少 32 bytes、HS384 至少 48、HS512 至少 64。六個字元的密碼不是密鑰, 而是一台筆電就能在五分鐘內離線爆破的對象。網路上已有公開工具,能 在幾秒內以字典檔破解短密鑰簽的 JWT。

請用完整熵生成密鑰,並把它放進密鑰管理工具:

# 32 random bytes, base64url-encoded — paste into your secret store
openssl rand -base64 32 | tr '+/' '-_' | tr -d '='
// Node — produce a fresh HS256 secret programmatically
import { randomBytes } from "node:crypto";
const secret = randomBytes(32); // raw bytes; encode for storage

絕不要把密鑰簽進原始碼、不要從環境變數名稱推導出來,也要定期輪替—— RFC 8725 §2.1 明確建議要輪替。

缺少 exp

沒有過期時間的 JWT,就是一張永久通行證。一旦外洩——忘了清的 log、 瀏覽器擴充功能、設錯的 proxy——你唯一能讓它失效的方法,是輪替簽署 密鑰,這會讓 所有 權杖一起失效,通常意味著全站登出。這不是一個 好的事故應變方案。

挑一個與爆炸半徑相稱的時窗:

  • Access token:5–15 分鐘。短到外洩之後能自動癒合。
  • Refresh token:數小時到數天,只儲存於可信客戶端,使用時輪替。
  • ID token(OIDC):分鐘等級;它在登入時證明身分,不該被重用。

伺服器端永遠要驗證 exp。有些函式庫預設會驗、有些不會——即便預設 會驗,也通常允許在測試時關閉。請審視你所有的 verify 呼叫點:

const claims = jwt.verify(token, key, {
  algorithms: ["RS256"],
  // do NOT pass { ignoreExpiration: true } in production
});

如果你需要長時效的授權(CLI 工具、CI runner),請發行短時效的 JWT 加 上獨立追蹤的 refresh token——不要把 exp 拉長到一年。

盲信 kid

kid(key id)是用於密鑰輪替的 header:簽發端在每個權杖上標記簽署 密鑰的 id,驗證端據此查出對應的公鑰,輪替就能不停機完成。陷阱在於 kidheader 的一部分,意思就是它由攻擊者控制。請當成不可信 輸入處理。

經典的失敗:

// path traversal — kid becomes a filesystem path
const key = fs.readFileSync(`/etc/keys/${header.kid}.pem`);
// attacker sends kid = "../../../../dev/null" or a public path

// SQL injection — kid becomes a query parameter
const key = await db.query(`SELECT pem FROM keys WHERE id = '${header.kid}'`);

// URL fetch — kid becomes a remote endpoint
const key = await fetch(header.kid).then(r => r.text());
// SSRF against internal services, or attacker-hosted "public key"

修法與處理任何其他不可信識別碼一致:在白名單中查找。維護一份以 kid 為索引的金鑰集合,凡是 kid 不在集合中的權杖一律拒絕,更 不要讓 kid 的值流到檔案系統呼叫、資料庫驅動或 HTTP client。對於 OIDC,JWKS URI 是固定設定;你抓回完整的集合、再依 kid 在結果中 挑選,而非反過來。

JWS 與 JWE——挑對的那一個

JWT 只是 payload 的形狀;外層包裝是 JWS(簽章)或 JWE(加密)。兩者 解決的是不同問題,而人們經常拿錯。

  • JWS 證明 簽發了權杖。任何人都讀得到 payload——只要把中段 做 base64url 解碼即可。這是完整性,不是機密性。
  • JWE 證明 payload 是什麼——整段都被加密。接收端需要解密金鑰; 其他人只看得到密文。

若你的權杖只攜帶 user id 與 expiry,JWS 就對了:接收端驗章後讀出 claim。 若權杖攜帶 session secret、不該讓使用者看見的功能旗標,或不希望寫入 log 的 PII,JWS 就不夠了。請改用 JWE,或者——更常見的做法——根本 不要把敏感資料放在權杖中,改在伺服器端以一個 opaque 的 session id 索引。

當你真的同時需要簽章與加密(一個已簽署的權杖要機密地送給已知接收端), 規格的標準解答是 巢狀 JWT:先簽章,再把 JWS 當成 JWE 的 payload 加 密。直接把未簽署的 payload 加密,會得到「有機密性但無認證」的東西, 這通常不是你要的。

驗證權杖

對於一次性的除錯——log 中撈到的權杖、curl 回應的 header、廠商給的範例 ——把它貼進 JWT 解碼與驗證工具。工具會在不驗章的情況下 先解出 header 與 payload(對任何權杖都安全),再依你提供的金鑰選擇性 地驗章。它實作了完整的演算法矩陣(HS/RS/PS/ES/EdDSA),並支援以 A128/192/256GCM 與常見金鑰包裝演算法解密 JWE。你貼上的當下,警告引擎 就會標出 alg=none、HMAC 密鑰過弱、權杖過期等本文提到的地雷。

請把它當成診斷工具,而不是生產程式碼的驗證步驟——生產驗證應該寫在你 服務內部的函式庫中,演算法寫死、金鑰從密鑰管理工具載入。

2026 年的演算法選擇

如果你今天要動工一個新系統,這是一個簡短的意見:

  • EdDSA(Ed25519) 是非對稱權杖的最佳預設值。金鑰短(32 bytes)、 簽章短(64 bytes)、決定性、沒有參數陷阱、驗證很快。josegolang.org/x/crypto/ed25519、Python cryptography 以及多數現代 JWT 函式庫都已支援。
  • RS256 在 OIDC 提供者與舊式 SaaS 整合中仍是最具互通性的選擇。 金鑰較大(至少 2048 bits,新金鑰建議 3072)但所有 JWT 函式庫都支援。 當你要發布給任意客戶端消費的 JWKS 時,請選 RS256。
  • HS256 適合服務對服務的權杖,前提是兩端都在你掌控之下、共享密鑰 能放在 vault 中管理。它不適合跨越信任邊界的權杖——任何拿到驗證金鑰 的人都能偽造權杖。
  • ES256 在 EdDSA 不可用時是合理的中間選項;它比 EdDSA 慢,且歷史上 在隨機 k 值的實作上有過陷阱,但現代函式庫已能正確處理。

請避開 RS1HS1,以及任何鎖在 SHA-1 上的東西——SHA-1 的碰撞抗性早 已破解,2026 年沒有理由再上線一個以 SHA-1 為基礎的權杖。none 演算法 全面禁用,包括測試 fixture,避免有人不小心把測試樁推到生產環境。

結尾檢查清單

JWT 驗證上線前的檢查:

  • verify 呼叫中寫死演算法。不依 header 退回。
  • HMAC 密鑰至少 32 bytes,以 CSPRNG 生成。
  • 非對稱金鑰來自 JWKS 或固定的 PEM,而非 kid
  • 每個權杖都有 exp,驗證端會強制檢查。
  • issaud 都與預期值比對。
  • 若權杖是純簽章的 JWS,payload 中不放敏感資料。
  • 測試 fixture 使用真實演算法,絕不用 none

防禦面其實很淺——半打不變量,全部都能在共用 helper 中強制。一旦失守, 攻擊面就是完整的帳號接管。值得每隔一段時間審計一次。