Engineering

JWT 在 Cloudflare Workers Edge 的簽章與驗證實戰

2026 年 5 月 5 日約 11 分鐘閱讀作者:Hao0321 Studio

Cloudflare Workers 的 runtime 不支援 Node.js built-ins,所以你不能 npm install jsonwebtoken。但其實也不需要 — Web Crypto API 內建在 V8 isolate 裡,HMAC-SHA256 簽章 30 行程式碼就搞定。這篇把整套生產環境用的 JWT 模組拆開講,包含三個我踩過的安全坑。

為什麼不用套件

選擇純 Web Crypto API 的理由:

核心:簽章函式

async function signToken(payload, secret) {
  const header = { alg: 'HS256', typ: 'JWT' };
  const headerB64 = b64url(JSON.stringify(header));
  const payloadB64 = b64url(JSON.stringify(payload));
  const data = headerB64 + '.' + payloadB64;

  const key = await crypto.subtle.importKey(
    'raw',
    new TextEncoder().encode(secret),
    { name: 'HMAC', hash: 'SHA-256' },
    false,
    ['sign']
  );
  const sig = await crypto.subtle.sign(
    'HMAC', key, new TextEncoder().encode(data)
  );
  return data + '.' + b64url(new Uint8Array(sig));
}

function b64url(input) {
  const bytes = typeof input === 'string'
    ? new TextEncoder().encode(input)
    : input;
  return btoa(String.fromCharCode(...bytes))
    .replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '');
}

關鍵點:

驗證函式

async function verifyToken(token, secret) {
  try {
    const [headerB64, payloadB64, sigB64] = token.split('.');
    if (!headerB64 || !payloadB64 || !sigB64) return null;

    const key = await crypto.subtle.importKey(
      'raw',
      new TextEncoder().encode(secret),
      { name: 'HMAC', hash: 'SHA-256' },
      false,
      ['verify']
    );
    const sigBytes = b64urlDecode(sigB64);
    const valid = await crypto.subtle.verify(
      'HMAC', key, sigBytes,
      new TextEncoder().encode(headerB64 + '.' + payloadB64)
    );
    if (!valid) return null;

    const payload = JSON.parse(
      new TextDecoder().decode(b64urlDecode(payloadB64))
    );
    if (payload.exp && payload.exp < Date.now() / 1000) return null;
    return payload;
  } catch {
    return null;
  }
}

function b64urlDecode(s) {
  const padded = s.replace(/-/g, '+').replace(/_/g, '/')
    + '=='.slice((s.length + 2) % 4);
  const binStr = atob(padded);
  return Uint8Array.from(binStr, c => c.charCodeAt(0));
}

安全坑 1:用 verify 不是 decode

致命錯誤jwt.decode() 只解碼不驗簽。攻擊者可以隨意改 payload 而你不會發現。一定要用 verify()

每次 code review 我看到 JWT 處理都會立刻搜 decode\(。如果你看到 jwt.decode(token) 後續被當成可信資料用 — 那是漏洞。

安全坑 2:alg=none 攻擊

JWT 規格有個惡名昭彰的「特性」:header 可以指定 alg: 'none',表示「沒有簽章」。如果你的驗證實作沒檢查 alg,攻擊者可以送一個沒簽章的 token,你會接受。

修法:明確只接受 HS256

const header = JSON.parse(b64urlDecode(headerB64));
if (header.alg !== 'HS256') return null;

安全坑 3:常數時間比較

有些自己刻 JWT 的人會用 === 比簽章字串:

// 危險寫法
if (computedSig !== providedSig) return null;

字串比較是 short-circuit 的,會在第一個不同的字元就 return。攻擊者可以透過時間差推測簽章。crypto.subtle.verify 內部用常數時間比較,所以正確用法是直接呼叫它,不自己比字串。

過期時間設計

JWT 的 exp 欄位是 Unix timestamp(秒):

const payload = {
  sub: userId,
  iat: Math.floor(Date.now() / 1000),
  exp: Math.floor(Date.now() / 1000) + 30 * 24 * 3600,
};

30 天是一個常見值。太短使用者要常登入,太長 session 被偷的影響大。配合 refresh token 可以縮短到 1 小時。

Secret 管理

JWT secret 絕對不能 commit。Cloudflare Workers 的做法:

$ wrangler secret put JWT_SECRET
✔ Enter a secret value: ********
🌀 Creating the secret for the Worker "hao-games-api"
✨ Success!

之後 worker 用 env.JWT_SECRET 讀取。Secret 不會出現在 dashboard、logs、wrangler.toml 任何地方。

Rotate(換金鑰)的策略

如果 secret 外洩,你需要 rotate。但直接換掉所有現有使用者的 session 都會作廢。實務做法:

  1. 新增 JWT_SECRET_NEW,新簽 token 用新 secret
  2. 驗證時先用新 secret 試,失敗再用舊 secret 試
  3. 30 天後(所有舊 token 過期),把舊 secret 刪掉,新 secret 改名為 JWT_SECRET

跟 Google ID Token 整合

外部 Google ID token 跟我們自己的 session JWT 是兩個不同的東西。流程:

  1. 前端拿到 Google ID token(Google 簽的)
  2. 後端驗證 Google ID token(呼叫 tokeninfo + 檢查 aud, exp, sub)
  3. 後端用自己的 JWT_SECRET 簽一個 session JWT 回給前端
  4. 後續所有 API 用 session JWT 認證

這個分層是必要的:Google ID token 1 小時就過期,不適合當 session。我們的 30 天 session JWT 才是真實的「登入狀態」。

結語

JWT 在 Edge 環境寫起來比想像中簡單。Web Crypto API 是被低估的工具 — 你不只能簽 JWT,還可以做 AES 加密、ECDSA 公鑰簽章、PBKDF2 密碼 hash,全部不用裝任何套件。

完整的 jwt.js(含 rotate、refresh)模組,我有計畫整理成 GitHub Gist。有興趣可以寄信 lo246179268@gmail.com