ch27-auth
在 GitHub 上檢視·4 個檔案·14.0 KB
取得並執行
這個範例可以獨立 clone 執行,不依賴其他章節。
npx degit mrsuner/cloudflare-serverless-platform-manuals/examples/ch27-auth
cd ch27-auth
npm install可用指令
npm run dev # wrangler dev
npm run deploy # wrangler deploy
npm run types # wrangler types --env-interface CloudflareBindings說明
ch27 — Auth on the Edge
Section titled “ch27 — Auth on the Edge”Probe project for chapter 27. Most of these routes exist to measure the cost of password hashing on Workers, and to surface a local-vs-production divergence that Cloudflare does not document anywhere.
Verified with wrangler 4.118.0, workerd 1.20260730.1, jose 6.2.6,
compatibility_date 2026-07-24, nodejs_compat.
npm installnpx wrangler types --env-interface CloudflareBindingsnpx wrangler dev --port 9033Routes
Section titled “Routes”| Route | What it shows |
|---|---|
/crypto-surface | Where each primitive actually lives. timingSafeEqual is on crypto.subtle, not crypto. DigestStream is on crypto, not globalThis. WebAssembly.compile exists — and throws. |
/algorithms | Ed25519, X25519, ECDSA, ECDH, RSA, and the legacy NODE-ED25519 name, with key-generation timings. |
/pbkdf2 | Iteration counts from 1,000 to 1,000,000, timed. |
/scrypt | node:crypto scrypt across the N*r*p cost ceiling, timed. |
/native-libs | bcrypt / argon2. |
/jose | HS256 sign+verify, aud mismatch, omitted aud, and EdDSA end to end. |
/timing-safe | Equal, unequal, and mismatched-length inputs. |
/digest-stream | Streaming SHA-256 without buffering. |
/kv-session | write → read → delete → read on KV. |
/do-session | create → verify → revoke → verify on a Durable Object. |
/access | Whether the Cloudflare Access assertion header/cookie is present. |
The headline: PBKDF2’s production cap does not exist locally
Section titled “The headline: PBKDF2’s production cap does not exist locally”curl -s localhost:9033/pbkdf2 | jq .results| iterations | local result |
|---|---|
| 100,000 | ok, 17 ms |
| 100,001 | ok, 17 ms |
| 600,000 | ok, 100 ms |
| 1,000,000 | ok, 165 ms |
Production caps PBKDF2 at 100,000 iterations. The cap is in workerd:
static constexpr size_t DEFAULT_MAX_PBKDF2_ITERATIONS = 100'000;and local workerd explicitly removes it:
kj::Maybe<size_t> checkPbkdfIterations(jsg::Lock&, size_t) const override { // No limit on the number of iterations in workerd return kj::none;}Exceeding it in production throws NotSupportedError:
Pbkdf2 failed: iteration counts above 100000 are not supported (requested 600000).
None of this appears in Cloudflare’s documentation. The citations above are
from workerd source. Write 100_000 and a comment explaining why.
The arithmetic that kills password login on the free plan
Section titled “The arithmetic that kills password login on the free plan”| Free | Paid | |
|---|---|---|
| CPU per request | 10 ms | 30 s default, 5 min max |
| measured | cost |
|---|---|
| PBKDF2 @ 100,000 (the production maximum) | 17 ms |
| scrypt N=16384, r=8, p=1 (Node’s default) | 53 ms |
| scrypt N=65536, r=8, p=2 (the ceiling) | 361 ms |
| RSA-2048 key generation | 48–59 ms |
| jose HS256 sign / verify | ~1 ms |
No compliant password hash fits in 10 ms. On the free plan, delegate authentication to an OAuth provider or Cloudflare Access.
scrypt’s cost ceiling — this one does apply locally
Section titled “scrypt’s cost ceiling — this one does apply locally”curl -s localhost:9033/scrypt | jqN * r * p must be ≤ 1,048,576. Over it:
RangeError: Scrypt failed: cost exceeds maximum (1048576).Unlike PBKDF2, server.c++ does not override checkScryptCost, so local and
production agree. Also undocumented.
No runtime code loading, three ways
Section titled “No runtime code loading, three ways”curl -s localhost:9033/native-libs# EvalError: Code generation from strings disallowed for this context
curl -s localhost:9033/crypto-surface | jq '{wasmCompile, wasmCompileWorks}'# "function" / CompileError: WebAssembly.compile(): Wasm code generation disallowed by embedderAnd a literal await import("bcrypt") does not even build:
✘ [ERROR] Build failed with 2 errors: ✘ [ERROR] Could not resolve "bcrypt" ✘ [ERROR] Could not resolve "argon2"typeof WebAssembly.compile === "function" is true, so feature detection
passes and only the call fails — the same shape as the Pipelines binding
(ch23), Astro.locals.runtime (ch25) and the Cache API stubs (ch6).
Argon2 on Workers works only via a statically imported, pre-compiled .wasm.
timingSafeEqual throws on length mismatch
Section titled “timingSafeEqual throws on length mismatch”curl -s localhost:9033/timing-safe | jq# differentLength: TypeError: Input buffers must have the same byte length.Undocumented (source: workerd crypto.c++). Comparing a user-supplied token
against a secret directly turns a mismatch into a thrown exception and leaks
length through the error path. Hash both sides to a fixed width first:
const [a, b] = await Promise.all([ crypto.subtle.digest("SHA-256", enc.encode(presented)), crypto.subtle.digest("SHA-256", enc.encode(expected)),]);return crypto.subtle.timingSafeEqual(a, b); // always 32 bytesjose does not check aud unless you ask
Section titled “jose does not check aud unless you ask”curl -s localhost:9033/jose | jq| verification | result |
|---|---|
correct audience | passes |
wrong audience | JWTClaimValidationFailed: unexpected "aud" claim value |
audience omitted | passes |
Every Cloudflare Access application in a team shares one JWKS, so a token
minted for any other app in the team validates against the same keys. Skipping
audience therefore accepts it. Cloudflare’s Access docs explain what aud
is and validate it in their examples, but contain no warning about omitting
it — that consequence is this chapter’s analysis, not a Cloudflare warning.
Also from those docs, and worth following: verify the
Cf-Access-Jwt-Assertion header, not the CF_Authorization cookie (“the
cookie is not guaranteed to be passed”), and match the JWT’s kid against
public_certs rather than reading public_cert. jose’s
createRemoteJWKSet handles the kid matching for you.
KV vs Durable Object for sessions
Section titled “KV vs Durable Object for sessions”curl -s localhost:9033/kv-session # afterDelete: "(null)" -- locally consistentcurl -s localhost:9033/do-session # afterRevoke: null -- consistent everywhereLocally KV is strongly consistent, so the revocation gap is invisible. In production KV changes take “up to 60 seconds or more” to reach other network locations, and the same key accepts only 1 write/sec — which rules out sliding expiration too.
Cloudflare’s storage-options page recommends KV for session data; KV’s own docs describe consistency and write limits that contradict that for anything session-mutating. Durable Objects are the documented answer when you need strong consistency.
原始碼
wrangler.jsonc
{
"$schema": "./node_modules/wrangler/config-schema.json",
"name": "ch27-auth",
"main": "src/index.ts",
"compatibility_date": "2026-07-24",
"compatibility_flags": ["nodejs_compat"],
"observability": { "enabled": true },
"kv_namespaces": [{ "binding": "SESSIONS", "id": "ch27-local" }],
"durable_objects": { "bindings": [{ "name": "SESSION_DO", "class_name": "SessionStore" }] },
"migrations": [{ "tag": "v1", "new_sqlite_classes": ["SessionStore"] }]
}package.json
{
"name": "ch27-auth",
"private": true,
"type": "module",
"scripts": {
"dev": "wrangler dev",
"deploy": "wrangler deploy",
"types": "wrangler types --env-interface CloudflareBindings"
},
"dependencies": {
"hono": "^4.12.32",
"jose": "^6.1.0"
},
"devDependencies": {
"@types/node": "^26.1.2",
"typescript": "^5.9.2",
"wrangler": "^4.118.0"
}
}src/index.ts
import { DurableObject } from "cloudflare:workers";
import * as jose from "jose";
const t = async (fn: () => unknown): Promise<Record<string, unknown>> => {
const started = Date.now();
try {
const v = await fn();
return { ok: v === undefined ? "(undefined)" : v === null ? "(null)" : v, ms: Date.now() - started };
} catch (e) {
return {
threwName: (e as Error).name,
threw: String((e as Error).message ?? e).slice(0, 220),
ms: Date.now() - started,
};
}
};
const enc = new TextEncoder();
const PW = "correct horse battery staple";
const SALT = enc.encode("0123456789abcdef");
// ---------------------------------------------------------------- PBKDF2
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 [...new Uint8Array(bits)].map((b) => b.toString(16).padStart(2, "0")).join("");
}
// ---------------------------------------------------------- node:crypto scrypt
async function scrypt(N: number, r: number, p: number): Promise<string> {
const { scrypt: nodeScrypt } = await import("node:crypto");
return await new Promise<string>((resolve, reject) => {
nodeScrypt(PW, Buffer.from(SALT), 32, { N, r, p, maxmem: 256 * 1024 * 1024 }, (err: Error | null, dk: Buffer) => {
if (err) reject(err);
else resolve(dk.toString("hex"));
});
});
}
// ------------------------------------------------------------------ DO store
export class SessionStore extends DurableObject {
async create(userId: string, ttlMs: number): Promise<string> {
const id = crypto.randomUUID();
this.ctx.storage.sql.exec(
"create table if not exists sessions (id text primary key, user_id text, expires integer)",
);
this.ctx.storage.sql.exec(
"insert into sessions (id, user_id, expires) values (?, ?, ?)",
id,
userId,
Date.now() + ttlMs,
);
return id;
}
async verify(id: string): Promise<{ userId: string } | null> {
this.ctx.storage.sql.exec(
"create table if not exists sessions (id text primary key, user_id text, expires integer)",
);
const rows = [
...this.ctx.storage.sql.exec<{ user_id: string; expires: number }>(
"select user_id, expires from sessions where id = ?",
id,
),
];
const row = rows[0];
if (!row || row.expires < Date.now()) return null;
return { userId: row.user_id };
}
async revoke(id: string): Promise<void> {
this.ctx.storage.sql.exec("delete from sessions where id = ?", id);
}
}
export default {
async fetch(request: Request, env: CloudflareBindings): Promise<Response> {
const url = new URL(request.url);
const q = url.searchParams;
switch (url.pathname) {
// Which crypto primitives actually exist on this runtime?
case "/crypto-surface": {
const subtle = crypto.subtle as unknown as Record<string, unknown>;
return Response.json({
cryptoKeys: Object.getOwnPropertyNames(Object.getPrototypeOf(crypto)),
hasTimingSafeEqual: typeof (crypto as unknown as { timingSafeEqual?: unknown })
.timingSafeEqual,
hasDigestStream: typeof (globalThis as { DigestStream?: unknown }).DigestStream,
hasCryptoDigestStream: typeof (crypto as unknown as { DigestStream?: unknown })
.DigestStream,
subtleMethods: Object.getOwnPropertyNames(Object.getPrototypeOf(subtle)),
randomUUID: typeof crypto.randomUUID,
// WebAssembly compilation at runtime -- documented as unavailable.
wasmCompile: typeof (WebAssembly as unknown as Record<string, unknown>)?.compile,
wasmInstantiate: typeof (WebAssembly as unknown as Record<string, unknown>)?.instantiate,
// The functions EXIST. Do they actually work? Minimal valid module:
wasmCompileWorks: await t(async () => {
const bytes = new Uint8Array([0, 0x61, 0x73, 0x6d, 1, 0, 0, 0]);
const mod = await (WebAssembly as unknown as {
compile(b: BufferSource): Promise<unknown>;
}).compile(bytes);
return { compiled: mod !== undefined };
}),
});
}
// Which algorithms does crypto.subtle actually accept?
case "/algorithms": {
const out: Record<string, unknown> = {};
for (const alg of [
"Ed25519",
"X25519",
"ECDSA",
"ECDH",
"RSASSA-PKCS1-v1_5",
"RSA-PSS",
"NODE-ED25519",
]) {
out[alg] = await t(async () => {
const params: Record<string, unknown> = { name: alg };
if (alg.startsWith("EC")) params.namedCurve = "P-256";
if (alg.startsWith("RSA")) {
params.modulusLength = 2048;
params.publicExponent = new Uint8Array([1, 0, 1]);
params.hash = "SHA-256";
}
if (alg === "NODE-ED25519") params.namedCurve = "NODE-ED25519";
const usages =
alg === "ECDH" || alg === "X25519" ? ["deriveBits"] : ["sign", "verify"];
const kp = (await crypto.subtle.generateKey(
params as unknown as Parameters<typeof crypto.subtle.generateKey>[0],
true,
usages as unknown as Parameters<typeof crypto.subtle.generateKey>[2],
)) as CryptoKeyPair;
return { alg: kp.privateKey.algorithm.name };
});
}
return Response.json(out);
}
// THE headline probe: PBKDF2 iteration ceiling.
// Cloudflare caps this at 100_000 in PRODUCTION. Local workerd does not.
case "/pbkdf2": {
const out: Record<string, unknown> = {};
for (const n of [1_000, 100_000, 100_001, 210_000, 600_000, 1_000_000]) {
out[String(n)] = await t(async () => (await pbkdf2(n)).slice(0, 16));
}
return Response.json({
note: "Production caps PBKDF2 at 100_000 iterations. If 600_000 succeeds here but your deployed Worker throws, that is why.",
results: out,
});
}
// node:crypto scrypt -- requires nodejs_compat.
case "/scrypt": {
const out: Record<string, unknown> = {};
// N*r*p must be <= 1_048_576
for (const [N, r, p] of [
[16384, 8, 1], // 131072 -- the classic "interactive" preset
[32768, 8, 1], // 262144
[65536, 8, 2], // 1048576 -- exactly at the documented ceiling
[131072, 8, 2], // 2097152 -- over
] as const) {
out[`N=${N},r=${r},p=${p} (N*r*p=${N * r * p})`] = await t(async () =>
(await scrypt(N, r, p)).slice(0, 16),
);
}
return Response.json({ results: out });
}
// Native module hashing libraries.
//
// NOTE: a static `import("bcrypt")` does not even BUILD -- esbuild fails
// with `Could not resolve "bcrypt"` before the Worker ever starts. The
// specifier below is assembled at runtime so the failure is observable
// as a runtime error instead. Both failure modes are real; the build-time
// one is what you will actually hit.
case "/native-libs": {
const dyn = (name: string) =>
(0, eval)(`import(${JSON.stringify(name)})`) as Promise<unknown>;
return Response.json({
note: 'A literal import("bcrypt") fails at BUILD time: Could not resolve "bcrypt".',
bcrypt: await t(() => dyn("bcrypt")),
argon2: await t(() => dyn("argon2")),
});
}
// jose@6 -- zero dependencies, pure WebCrypto.
case "/jose": {
const secret = enc.encode("a-32-byte-secret-value-for-hs256!");
const signed = await t(async () =>
new jose.SignJWT({ tenantId: "acme", scopes: ["links:read"] })
.setProtectedHeader({ alg: "HS256" })
.setIssuedAt()
.setIssuer("linkforge")
.setAudience("linkforge-api")
.setExpirationTime("15m")
.sign(secret),
);
const token = (signed.ok as string) ?? "";
const verified = await t(() =>
jose.jwtVerify(token, secret, { issuer: "linkforge", audience: "linkforge-api" }),
);
const wrongAud = await t(() =>
jose.jwtVerify(token, secret, { issuer: "linkforge", audience: "some-other-app" }),
);
const noAudCheck = await t(() => jose.jwtVerify(token, secret));
// Ed25519 (EdDSA) end to end
const eddsa = await t(async () => {
const { privateKey, publicKey } = await jose.generateKeyPair("EdDSA", {
extractable: true,
});
const jwt = await new jose.SignJWT({ sub: "u1" })
.setProtectedHeader({ alg: "EdDSA" })
.setExpirationTime("5m")
.sign(privateKey);
const { payload } = await jose.jwtVerify(jwt, publicKey);
return { sub: payload.sub };
});
return Response.json({
signed: { ...signed, ok: token.slice(0, 40) + "..." },
verified: verified.ok ? { ok: true, ms: verified.ms } : verified,
wrongAud,
noAudCheck: noAudCheck.ok ? { ok: "ACCEPTED -- this is the Access footgun" } : noAudCheck,
eddsa,
});
}
// timingSafeEqual, the Workers-specific one.
case "/timing-safe": {
const a = new Uint8Array([1, 2, 3, 4]);
const b = new Uint8Array([1, 2, 3, 4]);
const c = new Uint8Array([1, 2, 3, 5]);
const short = new Uint8Array([1, 2, 3]);
// NOTE: it lives on crypto.subtle, not on crypto.
const subtle = crypto.subtle as unknown as {
timingSafeEqual(a: ArrayBufferView, b: ArrayBufferView): boolean;
};
const tse = subtle.timingSafeEqual.bind(subtle);
return Response.json({
equal: await t(() => tse(a, b)),
notEqual: await t(() => tse(a, c)),
differentLength: await t(() => tse(a, short)),
});
}
// DigestStream: hash a stream without buffering it.
case "/digest-stream": {
return Response.json(
await t(async () => {
// NOTE: it lives on crypto, not on globalThis.
const Ctor = (crypto as unknown as { DigestStream?: new (a: string) => unknown })
.DigestStream;
if (!Ctor) return { missing: true };
const ds = new Ctor("SHA-256") as WritableStream & { digest: Promise<ArrayBuffer> };
const w = ds.getWriter();
await w.write(enc.encode("hello "));
await w.write(enc.encode("world"));
await w.close();
const hex = [...new Uint8Array(await ds.digest)]
.map((b) => b.toString(16).padStart(2, "0"))
.join("");
return { sha256: hex };
}),
);
}
// KV vs DO revocation. The point is read-your-own-write.
case "/kv-session": {
const id = crypto.randomUUID();
const wrote = await t(() => env.SESSIONS.put(`s:${id}`, "u1", { expirationTtl: 60 }));
const readBack = await t(() => env.SESSIONS.get(`s:${id}`));
const deleted = await t(() => env.SESSIONS.delete(`s:${id}`));
const afterDelete = await t(() => env.SESSIONS.get(`s:${id}`));
return Response.json({
note: "Locally KV is strongly consistent. In production it is NOT -- a deleted session can still verify at other colos for up to 60s.",
wrote,
readBack,
deleted,
afterDelete,
});
}
case "/do-session": {
const stub = env.SESSION_DO.getByName("tenant:acme");
const id = await stub.create("u1", 60_000);
const before = await stub.verify(id);
await stub.revoke(id);
const after = await stub.verify(id);
return Response.json({
note: "A DO is a single point of serialisation, so revocation is immediate everywhere.",
id,
before,
afterRevoke: after,
});
}
// Cloudflare Access
case "/access": {
return Response.json({
// Access puts the assertion in a HEADER, not a cookie.
headerPresent: request.headers.has("cf-access-jwt-assertion"),
cookiePresent: /CF_Authorization/.test(request.headers.get("cookie") ?? ""),
note: "Verify with jose.createRemoteJWKSet(new URL(`https://<team>.cloudflareaccess.com/cdn-cgi/access/certs`)) AND check `aud` against this application's AUD tag. Skipping `aud` accepts a token from ANY app in the team.",
});
}
default:
return new Response(
"/crypto-surface /algorithms /pbkdf2 /scrypt /native-libs /jose\n" +
"/timing-safe /digest-stream /kv-session /do-session /access\n",
{ status: 404 },
);
}
},
} satisfies ExportedHandler<CloudflareBindings>;tsconfig.json
{
"compilerOptions": {
"target": "esnext", "lib": ["esnext"], "module": "esnext",
"moduleResolution": "bundler", "types": ["./worker-configuration.d.ts", "node"],
"strict": true, "skipLibCheck": true, "noEmit": true, "isolatedModules": true
},
"include": ["src/**/*.ts", "worker-configuration.d.ts"]
}