Cloudflare Workers 的 runtime 不支援 Node.js built-ins,所以你不能 npm install jsonwebtoken。但其實也不需要 — Web Crypto API 內建在 V8 isolate 裡,HMAC-SHA256 簽章 30 行程式碼就搞定。這篇把整套生產環境用的 JWT 模組拆開講,包含三個我踩過的安全坑。
為什麼不用套件
選擇純 Web Crypto API 的理由:
- 套件兼容性:jsonwebtoken 依賴 Buffer、crypto,這些在 Workers runtime 不存在
- 部署大小:jose 之類的純 JS 套件約 100KB,Web Crypto 是 0KB
- 審計透明:自己寫的 30 行程式碼比 5,000 行依賴鏈好審
核心:簽章函式
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(/=+$/, '');
}
關鍵點:
b64url()— JWT 用的是 base64url,不是 base64。差別在+/=換成-_並去掉 paddingimportKey的 secret 直接用字串,不需要先 hashextractable: false— secret 不可被讀回,安全多一層
驗證函式
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 都會作廢。實務做法:
- 新增
JWT_SECRET_NEW,新簽 token 用新 secret - 驗證時先用新 secret 試,失敗再用舊 secret 試
- 30 天後(所有舊 token 過期),把舊 secret 刪掉,新 secret 改名為 JWT_SECRET
跟 Google ID Token 整合
外部 Google ID token 跟我們自己的 session JWT 是兩個不同的東西。流程:
- 前端拿到 Google ID token(Google 簽的)
- 後端驗證 Google ID token(呼叫 tokeninfo + 檢查 aud, exp, sub)
- 後端用自己的 JWT_SECRET 簽一個 session JWT 回給前端
- 後續所有 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。