Auth on the Edge
第 26 章的 AuthService 是假的 —— token.startsWith("t_") 就算過。這一章把它換成真的。
但在寫任何程式碼之前,必須先講一件事,因為它會推翻大多數人對「在 Workers 上做登入」的直覺:
在 Free plan 上,密碼登入實質上做不到。
這不是效能建議,是算術。Free plan 的 CPU 上限是每個請求 10 ms;而本章實測,一次符合 production 限制的 PBKDF2 需要 17 ms,一次最保守的 scrypt 需要 53 ms。
更糟的是,這件事在 wrangler dev 裡完全看不出來 —— 本機 workerd 既不套用 CPU 上限,也不套用 PBKDF2 的迭代上限。你可以在本機把一切測到完美,部署後每一次登入都失敗。
這一章前半就是在拆解這個陷阱。
27.1 密碼雜湊:實測數字
Section titled “27.1 密碼雜湊:實測數字”先看 Workers 上真正可用的兩個選項,以及它們各自的成本。
PBKDF2(WebCrypto,不需要任何 flag)
Section titled “PBKDF2(WebCrypto,不需要任何 flag)”async function pbkdf2(iterations: number): Promise<string> { const key = await crypto.subtle.importKey("raw", enc.encode(PW), "PBKDF2", false, ["deriveBits"]); const bits = await crypto.subtle.deriveBits( { name: "PBKDF2", salt: SALT, iterations, hash: "SHA-256" }, key, 256); return hex(bits);}本機 wrangler dev 實測:
| 迭代數 | 耗時 | 備註 |
|---|---|---|
| 1,000 | 1 ms | |
| 100,000 | 17 ms | production 的上限 |
| 100,001 | 17 ms | 本機不擋 |
| 210,000 | 35 ms | |
| 600,000 | 100 ms | OWASP 對 SHA-256 的建議值 |
| 1,000,000 | 165 ms | 本機照跑 |
六個值全部成功。 這正是問題所在。
production 有一個 100,000 的硬上限,而且 Cloudflare 沒有記載
Section titled “production 有一個 100,000 的硬上限,而且 Cloudflare 沒有記載”我在 Cloudflare 的 Web Crypto 文件裡找不到任何迭代上限的說明。這個限制只能從 workerd 的原始碼確認:
static constexpr size_t DEFAULT_MAX_PBKDF2_ITERATIONS = 100'000;而那段程式碼上方的註解,坦白得令人不安:
// By default, historically we've limited this to 100,000 iterations max. We'll set// that as the default for now. ... Note, this current default limit is *WAY* below// the recommended minimum iterations for pbkdf2.超過時丟出的錯誤:
JSG_FAIL_REQUIRE(DOMNotSupportedError, kj::str("Pbkdf2 failed: iteration counts above ", max, " are not supported (requested ", iterations, ")."));也就是 NotSupportedError,訊息形如:
Pbkdf2 failed: iteration counts above 100000 are not supported (requested 600000).而本機的 workerd 明確地把這個限制拿掉:
kj::Maybe<size_t> checkPbkdfIterations(jsg::Lock& lock, size_t iterations) const override { // No limit on the number of iterations in workerd return kj::none;}這是全系列到目前為止最惡劣的一個 local/production 分歧。 前面幾章遇到的分歧(第 21 章 Workflows 不 replay、第 22 章 Analytics Engine 不驗證)都是「本機比較寬鬆但無害」。這一個不同:它讓你把一個在 production 一定會壞的安全機制測到綠燈。
而且它完全沒有文件。上面所有引用都來自 workerd 原始碼,不是來自 developers.cloudflare.com。
實務結論:如果你要用 PBKDF2,就把迭代數寫成 100,000 並在程式碼裡註明原因。 不要寫 600,000 然後期待它能跑。
node:crypto scrypt(需要 nodejs_compat)
Section titled “node:crypto scrypt(需要 nodejs_compat)”const { scrypt } = await import("node:crypto");scrypt(password, salt, 32, { N, r, p, maxmem: 256 * 1024 * 1024 }, cb);本機實測:
| 參數 | N*r*p | 結果 |
|---|---|---|
| N=16384, r=8, p=1 | 131,072 | ok,53 ms |
| N=32768, r=8, p=1 | 262,144 | ok,109 ms |
| N=65536, r=8, p=2 | 1,048,576 | ok,361 ms |
| N=131072, r=8, p=2 | 2,097,152 | RangeError: Scrypt failed: cost exceeds maximum (1048576). |
上限是 N * r * p ≤ 1,048,576(workerd 的 DEFAULT_MAX_SCRYPT_COST = 1u << 20)。
和 PBKDF2 不同的是,這個上限在本機也生效 —— server.c++ 沒有覆寫 checkScryptCost。所以 scrypt 至少不會騙你。
Node.js 的預設值(N=16384, r=8, p=1)遠低於上限,所以一般用法不會撞到。
兩個都不可用的選項
Section titled “兩個都不可用的選項”bcrypt / argon2 原生模組 —— 連 build 都過不了。 實測寫一個 await import("bcrypt"):
✘ [ERROR] Build failed with 2 errors: ✘ [ERROR] Could not resolve "bcrypt" ✘ [ERROR] Could not resolve "argon2"不是執行期錯誤,是 esbuild 在打包階段就失敗。
執行期編譯 WebAssembly —— 函式存在,但呼叫必失敗。 實測:
{ "wasmCompile": "function", "wasmInstantiate": "function", "wasmCompileWorks": { "threwName": "CompileError", "threw": "WebAssembly.compile(): Wasm code generation disallowed by embedder" }}typeof WebAssembly.compile === "function" 是 true,所以 feature detection 會通過,只有真的呼叫才會炸。這已經是本系列第四次遇到同一個 pattern(第 23 章的 Pipelines binding、第 25 章的 Astro.locals.runtime、第 6 章的 Cache API stub)。
官方 web-standards 文件明列這四個為「基於安全理由不允許」:WebAssembly.compile、WebAssembly.compileStreaming、帶 buffer 參數的 WebAssembly.instantiate、WebAssembly.instantiateStreaming。
順帶,eval 也不行。 我原本想用 (0, eval)("import('bcrypt')") 繞過 build 期解析,結果拿到:
EvalError: Code generation from strings disallowed for this context把這幾條放在一起看,結論是:Workers 上沒有任何在執行期載入程式碼的後門。 所有程式碼必須在 build 時就進到 bundle 裡。這也解釋了為什麼社群的 argon2-on-Workers 方案都是「靜態 import 一個預先編譯好的 .wasm」—— 那是唯一可行的路徑。
算術:Free plan 做不到
Section titled “算術:Free plan 做不到”| 方案 | Free 上限 | 實測需要 |
|---|---|---|
| Free | 10 ms / 請求 | PBKDF2 @ 100k = 17 ms;scrypt 最輕 = 53 ms |
| Paid | 預設 30 s,可調到 5 min | 綽綽有餘 |
官方 limits 頁的定義是「CPU time 衡量 CPU 執行你的 Worker 程式碼的時間。等待網路請求(fetch()、KV 讀取、資料庫查詢)不計入 CPU time」。同步的金鑰衍生完全計入。
同一頁還有一句很應景的話:「大多數 Worker 消耗的 CPU time 很少。平均約 2.2 ms/請求。處理認證、SSR 或解析大型 payload 這類較重的工作,通常用掉 10-20 ms。」
所以:Free plan 上的密碼登入,是一個在本機完美運作、部署後穩定失敗的功能。 Cloudflare 沒有任何一頁把這件事講出來,本章的推導來自 limits 頁的數字加上實測。
如果你必須待在 Free plan,選項是:把驗證交給 OAuth provider(登入本身不做雜湊)、或者用 Cloudflare Access(27.5)。
Paid plan 上要調高上限:
{ "limits": { "cpu_ms": 300000 } }27.2 Session 儲存:官方建議與官方文件互相打架
Section titled “27.2 Session 儲存:官方建議與官方文件互相打架”Cloudflare 的 storage-options 頁講得很明確:
「我們建議使用 Workers KV 來儲存 session 資料、憑證(API keys)、以及/或設定資料。」
但 KV 自己的文件說:
「在做出變更的 Cloudflare 網路節點上,這些變更通常立即可見」,但「變更可能需要 60 秒或更久才會在其他網路節點可見。」
以及 limits 表裡那一行:同一個 key 每秒 1 次寫入(Free 與 Paid 皆同)。
把這兩件事套到 session 上:
| session 操作 | KV 的表現 |
|---|---|
| 讀取(驗證登入) | ✅ 很好,這正是 KV 的強項 |
| 建立 | ✅ 沒問題 |
| 登出/撤銷 | ❌ 最多 60 秒的漏洞窗口 —— 使用者按了登出,其他 colo 的請求仍然驗證通過 |
| sliding expiration | ❌ 每個請求都要重寫同一個 key,直接撞上 1 寫/秒 |
本機實測(/kv-session):
{ "wrote": "(undefined)", "readBack": "u1", "deleted": "(undefined)", "afterDelete": "(null)" }本機 KV 是強一致的 —— 刪掉立刻讀不到。所以撤銷的漏洞窗口也是一個本機測不出來的東西。
Durable Object 版本(/do-session):
{ "id": "3bb2beec-...", "before": { "userId": "u1" }, "afterRevoke": null }DO 是單一序列化點(第 14 章),撤銷立即全球生效。
| 需求 | 用什麼 |
|---|---|
| 讀多寫少的 session 查詢、可接受 60 秒撤銷延遲 | KV |
| 需要立即登出/撤銷、sliding expiration、單次性 token、per-user rate limit | Durable Object(每個使用者或每個租戶一顆) |
| session 要和 user profile 一起 join 查詢 | D1 |
| 完全無狀態、可接受 token 到期前無法撤銷 | JWT(見 27.3) |
**混合方案是最實用的:**JWT 帶短效期(15 分鐘)處理絕大多數請求,refresh token 存在 DO 裡。撤銷只需要撤 refresh token,最壞情況是 15 分鐘後失效 —— 這個窗口是你自己選的,而不是 KV 傳播延遲決定的。
KV 的文件自己也給了一個提示:
「達成 write-after-write 一致性的方法之一,是把某個 KV key 的所有寫入都送過一個對應的 Durable Object 實例。」
27.3 JWT:jose@6
Section titled “27.3 JWT:jose@6”import * as jose from "jose";零相依、純 WebCrypto、不需要任何 compatibility flag。實測 HS256 簽發 + 驗證各約 1 ms —— 相較於雜湊,這個成本可以忽略。
const token = await new jose.SignJWT({ tenantId: "acme", scopes: ["links:read"] }) .setProtectedHeader({ alg: "HS256" }) .setIssuedAt() .setIssuer("linkforge") .setAudience("linkforge-api") .setExpirationTime("15m") .sign(secret);
const { payload } = await jose.jwtVerify(token, secret, { issuer: "linkforge", audience: "linkforge-api", // ← 這一行不能省,理由見 27.5});實測三種驗證情境:
| 情境 | 結果 |
|---|---|
audience: "linkforge-api" | ✅ 通過 |
audience: "some-other-app" | JWTClaimValidationFailed: unexpected "aud" claim value |
完全不傳 audience | ✅ 通過 |
最後一列是重點:jose 預設不檢查 aud。 你不寫,它就不驗。
Ed25519(EdDSA)也實測可用,簽發到驗證約 1 ms。
crypto.subtle 支援的演算法
Section titled “crypto.subtle 支援的演算法”實測產生金鑰對:
| 演算法 | 結果 | 耗時 |
|---|---|---|
| Ed25519 | ✅ | 1 ms |
| X25519 | ✅ | 0 ms |
| ECDSA (P-256) | ✅ | 0 ms |
| ECDH (P-256) | ✅ | 0 ms |
| RSASSA-PKCS1-v1_5 (2048) | ✅ | 59 ms |
| RSA-PSS (2048) | ✅ | 48 ms |
NODE-ED25519(Cloudflare 舊名) | ✅ | 0 ms |
兩點:
- RSA 金鑰生成要 50-60 ms。 這是 Free plan 上限的 5-6 倍。金鑰生成絕對不能放在請求路徑上 —— 離線產生、存進 secret。
- Ed25519 是現在的第一選擇 —— 原生支援、快、簽章短。舊教學裡的
NODE-ED25519專有名稱仍然可用,但新程式碼直接寫Ed25519。
hono/jwt 的 verifyWithJwks
Section titled “hono/jwt 的 verifyWithJwks”Hono 也內建 JWT helper。實測 hono@4.12.33 的 dist/middleware/jwt/index.js:
export { jwt, verifyWithJwks, verify, decode, sign } from './jwt';verifyWithJwks 存在,但 Hono 的 JWT helper 文件頁只列了 sign / verify / decode,沒有提到它。 實際簽章:
verifyWithJwks(token: string, options: { keys?: HonoJsonWebKey[] jwks_uri?: string verification?: VerifyOptions // { iss, aud, nbf, exp, iat } allowedAlgorithms: readonly AsymmetricAlgorithm[] // ← 必填}, init?: RequestInit): Promise<JWTPayload>allowedAlgorithms 是必填的 —— 這是對 algorithm confusion 攻擊的防禦,設計得很好。另外 verify 的第三個參數現在是 algOrOptions(可以傳選項物件),文件頁還停在舊的位置參數形式。
27.4 Workers 專屬的兩個 crypto API
Section titled “27.4 Workers 專屬的兩個 crypto API”crypto.subtle.timingSafeEqual
Section titled “crypto.subtle.timingSafeEqual”注意它在 crypto.subtle 上,不是 crypto 上。 實測 typeof crypto.timingSafeEqual 是 "undefined";它出現在 Object.getOwnPropertyNames(Object.getPrototypeOf(crypto.subtle)) 裡。
crypto.subtle.timingSafeEqual(a, b) // ArrayBuffer | TypedArray實測:
| 輸入 | 結果 |
|---|---|
| 相同 4 bytes | true |
| 不同 4 bytes | false |
| 長度不同(4 vs 3) | TypeError: Input buffers must have the same byte length. |
最後一列很重要,而且沒有任何文件提到(來源是 workerd 的 crypto.c++):
JSG_REQUIRE(a.size() == b.size(), TypeError, "Input buffers must have the same byte length.");實務後果:如果你直接拿使用者提供的 token 和 secret 比對,長度不同會丟例外而不是回傳 false,而且錯誤路徑本身就洩漏了長度資訊。 正確做法是先把兩邊都雜湊成固定長度再比:
async function safeCompare(a: string, b: string): Promise<boolean> { const [ha, hb] = await Promise.all([ crypto.subtle.digest("SHA-256", enc.encode(a)), crypto.subtle.digest("SHA-256", enc.encode(b)), ]); return crypto.subtle.timingSafeEqual(ha, hb); // 永遠都是 32 bytes}workerd 原始碼自己也附了一句免責聲明:「這裡的實作完全依賴 CRYPTO_memcmp 的特性。除了檢查輸入型別與長度之外,我們不做任何額外驗證來確保這個操作真的是 timing safe。」
crypto.DigestStream
Section titled “crypto.DigestStream”同樣要注意位置:在 crypto 上,不是 globalThis 上。實測 typeof globalThis.DigestStream 是 "undefined",typeof crypto.DigestStream 是 "function"。
const ds = new crypto.DigestStream("SHA-256");const w = ds.getWriter();await w.write(enc.encode("hello "));await w.write(enc.encode("world"));await w.close();const hash = await ds.digest; // 實測 b94d27b9934d3e08...它是一個 WritableStream,不保留寫入的資料。用途是「對一個大檔案算雜湊而不把它讀進記憶體」—— 例如驗證 R2 上傳(第 12 章)的完整性,或算 webhook 簽章而不緩衝 body。
27.5 Cloudflare Access
Section titled “27.5 Cloudflare Access”如果你的應用是內部工具,最省事的 auth 就是根本不自己做 —— 讓 Cloudflare Access 在請求到達 Worker 之前完成驗證。
Access 會在請求上加一個 header:
Cf-Access-Jwt-Assertion: <JWT>官方文件明確指出要驗哪一個:
「透過瀏覽器發出的請求也會把 token 放在
CF_Authorizationcookie 裡。」 「我們建議驗證Cf-Access-Jwt-Assertionheader 而不是CF_Authorizationcookie,因為 cookie 不保證會被傳遞。」
JWKS 端點:
https://<your-team-name>.cloudflareaccess.com/cdn-cgi/access/certsimport { createRemoteJWKSet, jwtVerify } from "jose";
const JWKS = createRemoteJWKSet( new URL(`https://${TEAM}.cloudflareaccess.com/cdn-cgi/access/certs`),);
export async function verifyAccess(request: Request, policyAud: string) { const token = request.headers.get("cf-access-jwt-assertion"); if (!token) throw new Response("missing assertion", { status: 401 }); const { payload } = await jwtVerify(token, JWKS, { issuer: `https://${TEAM}.cloudflareaccess.com`, audience: policyAud, // ← 見下方警告 }); return payload;}aud 這件事,官方文件沒有警告,但後果很嚴重
Section titled “aud 這件事,官方文件沒有警告,但後果很嚴重”官方頁面確實解釋了 aud 是什麼:
「payload 裡的
audclaim 指出這個 JWT 對哪一個 application 有效。」 「Cloudflare Access 為每一個 application 指派一個獨特的 AUD tag。」
它的範例程式碼也確實驗了 aud(audience: env.POLICY_AUD)。但整頁沒有任何警告區塊、也沒有任何一句說明「不驗會怎樣」。
而後果是:同一個 team 底下的所有 Access application 共用同一組 JWKS。 所以一個為「內部 wiki」簽發的 token,在密碼學上對「production admin 面板」也是有效的 —— 如果你不驗 aud 的話。任何有權限進那個 wiki 的人,就能進你的 admin。
結合 27.3 實測到的「jose 不傳 audience 就不檢查」,這是一個非常容易寫出來的漏洞:程式碼看起來完全正常,測試全過,權限模型卻是壞的。
這一段的分析是本章自己的推導,不是引用 Cloudflare 的警告 —— 因為那個警告不存在。
文件裡確實有的那一個警告
Section titled “文件裡確實有的那一個警告”同一頁有一個關於金鑰輪替的建議,值得照做:
「不要從
public_cert取得目前的金鑰,因為你的 origin 可能從過期的快取讀到舊值。請改為比對 JWT 裡的kid與public_certs中對應的憑證。」
jose 的 createRemoteJWKSet 已經正確處理 kid 比對與快取,所以用它就好。
27.6 函式庫現況(2026-08-01)
Section titled “27.6 函式庫現況(2026-08-01)”這個生態系在過去一年變動很大,而過時的教學特別多。
| 函式庫 | 版本 / 日期 | 狀態 |
|---|---|---|
| jose | 6.2.6 | ✅ 純 WebCrypto、零相依、不需 flag。JWT 就用它 |
| Better Auth | core 1.6.25(2026-07-23) | ⚠️ 可用但有坑,見下 |
Auth.js (@auth/core) | 0.41.3(2026-07-20) | ⚠️ 仍標示 experimental |
@auth/d1-adapter | 1.11.3(2026-07-20) | ✅ 存在且維護中 |
| Lucia | 3.2.2(2024-10-20) | ❌ 2025-03 已棄用 |
| OpenAuth | 0.4.3(2025-03-04) | ❌ 約 17 個月沒有新版本 |
Better Auth
Section titled “Better Auth”先更正一個常見錯誤:@better-auth/cloudflare 這個套件不存在。 npm registry 查詢回傳 Not found。社群套件叫 better-auth-cloudflare(v0.3.1,2026-07-23,作者 zpg6)。
不過從 Better Auth 1.5(2026-02-28)起,D1 已經是原生支援:
「Better Auth 現在原生支援 Cloudflare D1 作為一等公民的資料庫選項。」「直接傳入你的 D1 binding —— 不需要自訂 adapter 設定。」「注意 D1 不支援 interactive transaction,Better Auth 改用 D1 的
batch()API 來達成原子性。」
(這句話正好對應第 9、10 章實測到的 db.transaction() → Failed query: begin。)
Cloudflare 官方對 Better Auth 零支援 —— 沒有 docs 頁、沒有 template。Better Auth 自己的文件也沒有 Cloudflare 指南(/docs/integrations/cloudflare 回 404),而且 database 文件頁有一個標題叫「Example: Cloudflare D1」卻沒有內容 —— 部落格宣稱一等公民支援、文件頁是空的。
目前最接近一手來源的是 Hono 的官方文件頁 hono.dev/examples/better-auth-on-cloudflare。它示範的關鍵模式是:
// auth 實例必須 per-request 建構,因為 binding 在 env 上而不是全域app.on(["GET", "POST"], "/api/auth/*", (c) => auth(c.env).handler(c.req.raw));最大的坑,和 27.1 完全同一件事: Better Auth 預設的密碼雜湊是 @noble/hashes 的純 JS scrypt,參數 N: 16384, r: 16, p: 1。注意 r: 16 —— 比 Node 預設的 r: 8 重一倍。已知 issue #8860「Worker exceeded CPU time limit」在 email/password 註冊時發生,維護者已確認。相關的 #8456 請求改用原生 node:crypto,說明目前「總是使用 @noble/hashes/scrypt」。
解法是自己覆寫:
import { scryptSync, randomBytes, timingSafeEqual } from "node:crypto";
betterAuth({ emailAndPassword: { enabled: true, password: { hash: async (password) => { const salt = randomBytes(16); // N*r*p = 131072,遠低於 1_048_576 上限;實測約 53 ms const dk = scryptSync(password, salt, 32, { N: 16384, r: 8, p: 1 }); return `${salt.toString("hex")}:${dk.toString("hex")}`; }, verify: async ({ hash, password }) => { const [saltHex, dkHex] = hash.split(":"); const dk = scryptSync(password, Buffer.from(saltHex, "hex"), 32, { N: 16384, r: 8, p: 1 }); return timingSafeEqual(dk, Buffer.from(dkHex, "hex")); }, }, },});(我無法確認原生 scrypt 的修正有沒有進 1.6.25,請自行以你安裝的版本驗證。)
session.cookieCache:Workers 上投報率最高的開關
Section titled “session.cookieCache:Workers 上投報率最高的開關”betterAuth({ session: { cookieCache: { enabled: true, maxAge: 5 * 60 } },});官方說明:
「每次呼叫
useSession或getSession都打資料庫並不理想,尤其在 session 不常變動的時候。Cookie caching 的做法是把 session 資料存在一個短效、已簽章的 cookie 裡。」
預設是關閉的。 在 Workers 上它移除了每個已登入請求的一次 D1/KV 往返 —— 對延遲和成本都是直接的改善。
代價官方也寫了:
「被撤銷的 session 在其他裝置上可能維持有效,直到 cookie cache 過期。」
這正是 27.2 那個權衡的另一個版本:maxAge 就是你選擇的撤銷窗口。5 分鐘通常是合理的。
注意:把
cookieCache說成「Better Auth 推薦給 serverless」是網路上常見但沒有一手來源的說法。官方沒有這樣建議;上面的推薦是本章基於 Workers 特性的判斷。
Auth.js
Section titled “Auth.js”npm install @auth/core @auth/d1-adapterimport { D1Adapter, up } from "@auth/d1-adapter";// 初始化時執行 up(env.DB) 建立 accounts / sessions / users / verification_tokens@auth/core 建立在 Web 標準的 Request / Response 之上,這讓它在 Workers 上很自然。但它的參考頁至今仍掛著:
「⚠️ Experimental —
@auth/core正在積極開發中。」
而且 Cloudflare 沒有官方 Auth.js 指南。
我看到不少教學宣稱「
@auth/core不需要nodejs_compat」。這很可能是對的(它建立在 Web 標準上,而 WebCrypto 本來就不需要 flag),但沒有任何一手來源這樣說,而且 adapter 與 provider 可能間接引入 Node API。本章不做這個保證 —— 請自己測。
不要用的兩個
Section titled “不要用的兩個”Lucia 已棄用。 官網現在寫著:
「Lucia 在 2025 年 3 月被棄用。本網站於 2026 年 7 月更新。」
npm 上 lucia@3.2.2 停在 2024-10-20,帶有棄用通知。它沒有推薦替代品 —— 現在定位是學習資源,指向一個單檔的 drop-in 實作與 Auth Book。如果你想真正理解 session 認證的機制,那本書值得讀。
OpenAuth 實質停更。 @openauthjs/openauth 最新版 0.4.3 停在 2025-03-04,到現在約 17 個月。repo 沒有正式的 archived / unmaintained 標示,所以嚴格說它沒有被宣告死亡 —— 但新專案不該採用。
⚠️ 值得一提的是:Cloudflare 官方的 templates gallery 至今仍然提供
openauth-template(「Deploy an OpenAuth server on Cloudflare Workers」)。一個官方 template 指向一個 17 個月沒發版的套件。
27.7 LinkForge:多租戶 auth
Section titled “27.7 LinkForge:多租戶 auth”把前面所有結論組合起來。
訪客請求 │ ├─ 帶 Cf-Access-Jwt-Assertion? → Access 驗證(內部管理後台) │ ├─ 帶 Bearer JWT? → jose 驗證(15 分鐘效期,無狀態) │ └─ 帶 refresh cookie? → SessionStore DO 驗證 → 換發新 JWTaccess token:短效、無狀態
Section titled “access token:短效、無狀態”type AccessClaims = { sub: string; // userId tid: string; // tenantId scp: string[]; // scopes role: "owner" | "admin" | "member";};
export async function issueAccess(env: Env, s: Session): Promise<string> { return new jose.SignJWT({ tid: s.tenantId, scp: s.scopes, role: s.role }) .setProtectedHeader({ alg: "EdDSA" }) // 第 27.3 節:原生、快、簽章短 .setSubject(s.userId) .setIssuedAt() .setIssuer("https://linkforge.dev") .setAudience("linkforge-api") .setExpirationTime("15m") .sign(await privateKey(env));}驗證放在第 26 章那個 AuthService 裡,透過 RPC 被 API Worker 呼叫:
export class AuthService extends WorkerEntrypoint<Env> { async verify(token: string): Promise<Session | null> { try { const { payload } = await jose.jwtVerify(token, await publicKey(this.env), { issuer: "https://linkforge.dev", audience: "linkforge-api", // 絕對不可省 }); return { userId: payload.sub!, tenantId: payload.tid as string, scopes: payload.scp as string[], role: payload.role as Session["role"], }; } catch { return null; } }}第 26 章實測過,一次 RPC 呼叫的成本很低,而且 AuthService 的實作可以獨立演進 —— API Worker 不需要知道 JWT 的存在。
refresh token:DO,可立即撤銷
Section titled “refresh token:DO,可立即撤銷”export class SessionStore extends DurableObject { // 每個「使用者」一顆 DO:getByName(`user:${userId}`) async issue(userId: string, ua: string): Promise<string> { const id = crypto.randomUUID(); this.ctx.storage.sql.exec( "insert into sessions (id, user_id, ua, expires) values (?, ?, ?, ?)", id, userId, ua, Date.now() + 30 * 864e5); return id; }
async rotate(old: string): Promise<string | null> { const row = [...this.ctx.storage.sql.exec("select user_id, ua from sessions where id = ?", old)][0]; if (!row) return null; // 單次性:舊的立刻失效。偵測到重放就是 token 外洩。 this.ctx.storage.sql.exec("delete from sessions where id = ?", old); return await this.issue(row.user_id as string, row.ua as string); }
async revokeAll(userId: string): Promise<void> { this.ctx.storage.sql.exec("delete from sessions where user_id = ?", userId); }}rotate 的單次性設計是 DO 才做得到的 —— 第 14 章的 input gate 保證了「讀取 + 刪除 + 新增」不會有競態。KV 做不到這件事(1 寫/秒,而且沒有原子性)。
「登出所有裝置」是 revokeAll,立即生效;access token 最多 15 分鐘後失效。
API token(給程式呼叫)
Section titled “API token(給程式呼叫)”短網址服務一定會有人寫腳本呼叫。API token 不該是 JWT(無法撤銷),也不該明文存。
// 產生:只在建立時回傳一次const raw = `lf_${crypto.randomUUID().replace(/-/g, "")}`;const digest = await crypto.subtle.digest("SHA-256", enc.encode(raw));await env.DB.prepare("insert into api_tokens (tenant_id, prefix, hash, name) values (?,?,?,?)") .bind(tenantId, raw.slice(0, 11), hex(digest), name).run();
// 驗證:用 prefix 縮小範圍,再用 timingSafeEqual 比對const prefix = presented.slice(0, 11);const row = await env.DB.prepare("select hash, tenant_id from api_tokens where prefix = ?") .bind(prefix).first<{ hash: string; tenant_id: string }>();if (!row) return null;const presentedHash = await crypto.subtle.digest("SHA-256", enc.encode(presented));if (!crypto.subtle.timingSafeEqual(presentedHash, hexToBytes(row.hash))) return null;注意這裡用的是單次 SHA-256,不是 PBKDF2/scrypt。 這是刻意的:API token 是 128 bit 的高熵隨機值,不是人選的密碼 —— 它沒有被暴力破解的風險,所以不需要慢雜湊。把 27.1 那 53 ms 花在每一次 API 呼叫上是純粹的浪費。
慢雜湊只用在低熵的祕密(人類密碼)上。 這個區分很多實作會搞錯。
而且兩邊的雜湊都是 32 bytes,所以 27.4 那個 timingSafeEqual 長度陷阱自然被避開了。
27.8 本章實測結論彙整
Section titled “27.8 本章實測結論彙整”| # | 結論 | 影響 |
|---|---|---|
| 1 | 本機 PBKDF2 1,000,000 迭代成功(165 ms) | production 上限是 100,000 |
| 2 | 上限來自 workerd DEFAULT_MAX_PBKDF2_ITERATIONS = 100'000,Cloudflare 文件完全沒記載 | 只能從原始碼確認 |
| 3 | server.c++ 明確在本機關掉這個限制(return kj::none) | 在本機測到綠燈、部署後必炸的分歧 |
| 4 | 超限錯誤:NotSupportedError / Pbkdf2 failed: iteration counts above 100000 are not supported (requested N). | 告警規則要對這個 |
| 5 | PBKDF2 @ 100k 實測 17 ms;@ 600k 實測 100 ms | Free plan 上限 10 ms |
| 6 | scrypt N*r*p ≤ 1,048,576,超出丟 RangeError: Scrypt failed: cost exceeds maximum (1048576). | 這個上限本機也生效 |
| 7 | scrypt (16384,8,1) 實測 53 ms;(65536,8,2) 實測 361 ms | |
| 8 | 綜合 5、7:Free plan 的 10 ms 跑不完任何合格的密碼雜湊 | 密碼登入實質需要 Paid |
| 9 | import("bcrypt") / import("argon2") 在 build 期失敗:Could not resolve | 不是執行期錯誤 |
| 10 | typeof WebAssembly.compile === "function" 為 true,呼叫丟 CompileError: Wasm code generation disallowed by embedder | feature detection 無效(本系列第四次) |
| 11 | eval 丟 EvalError: Code generation from strings disallowed for this context | Workers 沒有執行期載入程式碼的後門 |
| 12 | timingSafeEqual 在 crypto.subtle 上,不在 crypto 上 | |
| 13 | 長度不同時丟 TypeError: Input buffers must have the same byte length.(未記載) | 比對前必須先雜湊成固定長度 |
| 14 | DigestStream 在 crypto 上,不在 globalThis 上 | |
| 15 | Ed25519 / X25519 / ECDSA / ECDH 皆原生支援且 ≤1 ms;RSA 金鑰生成 48-59 ms | RSA keygen 不可放在請求路徑 |
| 16 | 舊名 NODE-ED25519 仍可用 | 新程式碼用 Ed25519 |
| 17 | jose 不傳 audience 時不檢查 aud,實測直接放行 | 與下一列合起來就是漏洞 |
| 18 | 同一 Access team 的所有 app 共用 JWKS | 不驗 aud = 任一 app 的 token 都能進 |
| 19 | Cloudflare Access 文件沒有關於 aud 後果的警告;有的是 kid 比對的警告 | 第 18 列是本章推導,非官方警告 |
| 20 | Access 官方建議驗 Cf-Access-Jwt-Assertion header 而非 CF_Authorization cookie | cookie 不保證被傳遞 |
| 21 | 本機 KV 強一致(刪除後立即讀不到);production 最長 60 秒 | 撤銷漏洞窗口本機測不出來 |
| 22 | KV 每 key 每秒 1 寫 | sliding expiration 做不了 |
| 23 | Cloudflare 建議用 KV 存 session,但 KV 文件的一致性與寫入限制與此矛盾 | 官方沒有指出這個矛盾 |
| 24 | @better-auth/cloudflare 不存在;社群套件是 better-auth-cloudflare | |
| 25 | Better Auth 預設用 @noble/hashes scrypt(r: 16),已知會超 CPU 上限(issue #8860) | 必須自訂 password.hash/verify |
| 26 | Better Auth 的 session.cookieCache 預設關閉 | Workers 上投報率最高的開關 |
| 27 | @auth/core 0.41.3 仍標示 experimental;@auth/d1-adapter 1.11.3 維護中 | |
| 28 | Lucia 2025-03 棄用,不推薦替代品,改為學習資源 | |
| 29 | OpenAuth 最新版停在 2025-03-04(約 17 個月) | 但 Cloudflare 官方 templates 仍提供 openauth-template |
| 30 | hono/jwt 有 verifyWithJwks(allowedAlgorithms 必填),但文件頁沒有列出 |
27.9 動手練習
Section titled “27.9 動手練習”- 把
/pbkdf2部署到 production,確認 100,001 迭代真的丟NotSupportedError—— 這是本章唯一我無法在離線環境驗證的一半。 - 在 Free plan 上部署一個用
scrypt(16384, 8, 1)的登入端點,觀察exceededCpu的 invocation outcome。 - 寫一個故意不傳
audience的 Access 驗證函式,用另一個 Access application 的 token 打它,確認 27.5 那個漏洞是真的。 - 用
crypto.DigestStream對一個 100 MB 的 R2 物件算 SHA-256,比較它與「先arrayBuffer()再digest()」的記憶體用量(第 12 章的 128 MB 上限)。 - 實作 27.7 的 refresh token 輪替,然後故意重放一個已使用的 token,設計偵測到重放時要怎麼處置(提示:撤銷該使用者的全部 session)。
- Web Crypto · node:crypto · Web standards(不允許的 API) · WebAssembly
- Workers limits · Storage options · KV how it works · KV limits
- Cloudflare Access — Validating JSON web tokens
- workerd 原始碼:
limit-enforcer.h·server.c++·crypto/impl.c++· workerd#1346 - Better Auth session management · Better Auth 1.5 blog · issue #8860 · issue #8456 · Hono: Better Auth on Cloudflare
- Auth.js core reference · @auth/d1-adapter · Lucia · Auth Book
- jose · Hono JWT helper(⚠️ 未列出
verifyWithJwks)
第 27 章結束 Part 5。下一章起進入 Part 6 — 進階平台能力:第 28 章 Hyperdrive,把你既有的 Postgres / MySQL 接到 Workers 上,而不是把資料搬進 D1。