跳到內容

ch27-auth

對應 27. Auth on the Edge

取得並執行

這個範例可以獨立 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

說明

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.

Terminal window
npm install
npx wrangler types --env-interface CloudflareBindings
npx wrangler dev --port 9033
RouteWhat it shows
/crypto-surfaceWhere each primitive actually lives. timingSafeEqual is on crypto.subtle, not crypto. DigestStream is on crypto, not globalThis. WebAssembly.compile exists — and throws.
/algorithmsEd25519, X25519, ECDSA, ECDH, RSA, and the legacy NODE-ED25519 name, with key-generation timings.
/pbkdf2Iteration counts from 1,000 to 1,000,000, timed.
/scryptnode:crypto scrypt across the N*r*p cost ceiling, timed.
/native-libsbcrypt / argon2.
/joseHS256 sign+verify, aud mismatch, omitted aud, and EdDSA end to end.
/timing-safeEqual, unequal, and mismatched-length inputs.
/digest-streamStreaming SHA-256 without buffering.
/kv-sessionwrite → read → delete → read on KV.
/do-sessioncreate → verify → revoke → verify on a Durable Object.
/accessWhether 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”
Terminal window
curl -s localhost:9033/pbkdf2 | jq .results
iterationslocal result
100,000ok, 17 ms
100,001ok, 17 ms
600,000ok, 100 ms
1,000,000ok, 165 ms

Production caps PBKDF2 at 100,000 iterations. The cap is in workerd:

src/workerd/io/limit-enforcer.h
static constexpr size_t DEFAULT_MAX_PBKDF2_ITERATIONS = 100'000;

and local workerd explicitly removes it:

src/workerd/server/server.c++
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”
FreePaid
CPU per request10 ms30 s default, 5 min max
measuredcost
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 generation48–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”
Terminal window
curl -s localhost:9033/scrypt | jq

N * 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.

Terminal window
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 embedder

And 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.

Terminal window
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 bytes
Terminal window
curl -s localhost:9033/jose | jq
verificationresult
correct audiencepasses
wrong audienceJWTClaimValidationFailed: unexpected "aud" claim value
audience omittedpasses

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.

Terminal window
curl -s localhost:9033/kv-session # afterDelete: "(null)" -- locally consistent
curl -s localhost:9033/do-session # afterRevoke: null -- consistent everywhere

Locally 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"]
}