agentleFS
okayusokayus/okayus-skills/skills/cloudflare-workers-pat-bearer-auth/SKILL.md

cloudflare-workers-pat-bearer-auth

Skill0 starsChanged 7 days agoMITView on GitHub

Add personal access tokens (PATs, Bearer header) to a Hono + D1 Cloudflare Worker so CLIs, AI agents and sibling apps can act as the user next to the cookie session. The receiver mints the token, stores only a peppered hash and never needs to know the caller. Use when building a CLI or an app-to-app push (post today's quiz results into my diary app), when a Bearer request gets `403 csrf_origin_mismatch`, when a PAT must not mint more PATs, when revoking one token must not log the user out, when a push between your own Workers never arrives (a 404 whose body says error code 1042), when a receiver change starts answering the sender with 400, or when sender and receiver must share a request contract (a JSON Schema generated from the receiver's zod schema and contract-tested by the caller). Not Cloudflare API tokens.

  • Reads credentials
  • Sends data out

The file

We split this skill into the rules it gives an agent

4 rules. About 0 are common, 2 are distinctive, and 2 are very specific to this project.

  • ≈0

    Common · estimate

    Many files like this have a rule of this kind.

  • ≈2

    Distinctive · estimate

    This author's own choices. Worth a look.

  • 2

    Very specific

    Tied to their project's names, files or tools. Probably skip.

  • Common
  • Distinctive
  • Very specific
  1. Very specific:

    every non-GET call from the CLI returns 403 csrf_origin_mismatch → the CSRF Origin check is not exempting Bearer

    line 24
  2. Distinctive:

    Do not use for:

    line 30
  3. Very specific:

    Keep the schema out of the runtime: a public /api/schema route is one more unauthenticated surface on a private app, and raw GitHub already …

    line 222
  4. Distinctive:

    Do not put the receiver's contract in a skill either — this section is the *procedure*.

    line 223

≈ “Common” is an estimate: at least 1 in 5 of the 350,495 skill files we read have a rule of the same kind, not the same words. “Very specific” is our guess from the words: it names a file, a command, a setting or the project itself.

Keep what fits and drop the rest. Your version, ready for your agents.
Files
SKILL.md

Cloudflare Workers PAT (Bearer) auth for machine callers

Get a Hono + D1 Worker that already has cookie sessions to the state where a CLI, an AI agent or another app can act as a specific user with a token the user minted in the settings UI — the token is shown once, only sha256(token + pepper) is stored, every existing route accepts either credential, a PAT can never mint another PAT, and revoking one token touches nothing else.

Extracted from mazuoboeru (ADR-0001, worker/auth/pat.ts; in production since Phase 1, 2026-06; the mzo CLI on npm has used it since 2026-08-13), 2026-08-22. The first receiver-side consumer is kokemusu (POST /api/posts behind post:write, in production since 2026-09-03, #31).

The shape in one sentence: the receiving app issues the token (session-only route), the calling side stores it (env var, another Worker's secret) and sends Authorization: Bearer <token>, and the receiver's auth middleware tries the PAT before the cookie — so the receiver never needs to know which app is calling, only which user and which scopes.

When to use this skill

  • A CLI or AI agent must create / update things as the user (mazuoboeru: AI-driven quiz mass-production through mzo)
  • App-to-app push on the user's behalf where the receiver must stay ignorant of the sender ("my quiz app posts today's results into my diary app")
  • Symptoms in an existing app:
    • every non-GET call from the CLI returns 403 csrf_origin_mismatch → the CSRF Origin check is not exempting Bearer
    • a token can call POST /api/tokens and mint more tokens → management route is behind requireAuth instead of requireSession
    • revoking a token logs the user out (or vice versa) → sessions and PATs share a table
    • a D1 dump would hand out live credentials → raw tokens stored
    • one busy agent writes last_used_at on every request → no throttle

Do not use for:

  • Third-party developer apps that need a consent screen, refresh tokens or per-app client ids — that is being an OAuth provider, a different design
  • Internal Worker-to-Worker calls with no user behind them — a service binding alone is enough, no token at all (a push on a user's behalf still carries that user's PAT, and between Workers on one zone it travels over a service binding — see the 1042 trap)
  • Anonymous API keys with no user behind them (metrics, public read APIs) — no user_id, no scopes; a different table
  • Cloudflare API tokens (wrangler deploy, CI) — unrelated despite the name; see cloudflare-api-token-permissions

Decision: why a PAT (and when not)

OptionPer-user machine accessWhy it lost / won (source)
PAT (Bearer)✓~150 LOC, one table, works from curl; the user copies one secret by hand (ADR-0001)
OAuth Device Authorization Grantlaternicer CLI login, but +200 LOC and a polling endpoint; can be layered on top of PATs (ADR-0001)
Cloudflare Access service token✗a machine identity, not a user; authz lives in the dashboard
Service binding✗ (as authz)only within one account — a self-hosted receiver in another account can't be bound; inside one account it is the transport for a PAT push (1042 trap), not a replacement for the user's token
HMAC-signed webhook✗the same shared secret minus the user, scopes, revocation list and UI; pick it only when the receiver has no users / sessions at all

Constraints you accept: the raw token crosses a boundary by hand (so scope it minimally and make revocation one click), and a pepper rotation invalidates every token.

Deliverables (completion criteria)

  • api_token(id, user_id → users CASCADE, name, token_hash UNIQUE, scopes JSON, created_at, last_used_at, expires_at, revoked_at) + INDEX (user_id, revoked_at) (references/schema-and-crypto.md)
  • PAT_PEPPER set with wrangler secret put in prod, in .dev.vars locally, listed (key only) in .dev.vars.example
  • Token = <app>_pat_ + base64url(32 random bytes); the raw value is returned once, from POST /api/tokens (201), never again
  • validatePat rejects in this order: no Bearer → wrong prefix → hash miss → revoked_at → expires_at; one indexed lookup joined with user; last_used_at written at most hourly
  • authenticate() tries the PAT before the session and records c.var.authMethod as "pat" | "session"
  • /api/tokens/* sits behind requireSession → a PAT gets 403 session_required
  • PAT-reachable mutations stack requireAuth, requireScope("<resource>:write") — in that order; sessions pass every scope
  • SCOPES is a const tuple; stored scopes unknown to the current build are dropped on parse
  • The CSRF Origin check skips requests that carry Authorization: Bearer
  • No console.* of the Authorization header or a token anywhere (Workers Observability persists it); log the token id
  • Settings UI: name → create → show-once card → list (name / created / last used / state) → revoke
  • Caller docs name the env var (<APP>_PAT), the base-URL override and a whoami smoke command
  • The receiver publishes the caller contract — docs/senders.md + a JSON Schema generated from the zod body schema and pinned by a file snapshot in pnpm test; every caller vendors that schema and contract-tests its builder with z.fromJSONSchema (see The caller side: the contract)
  • Every UNVERIFIED: bullet below checked on the real app and written back

Architecture in one screen

caller: CLI env var / another Worker's secret / agent sandbox env
  └─ Authorization: Bearer <app>_pat_…  ──▶ Worker (Hono)
                                              ├─ securityHeaders
                                              ├─ csrf             non-GET: Origin === ORIGIN — SKIPPED for Bearer
                                              ├─ optionalAuth     PAT first → cookie session → c.var.{user,authMethod,scopes}
                                              ├─ /api/tokens/*    requireSession   (cookie only: a PAT can't mint PATs)
                                              └─ domain routes    requireAuth [, requireScope("x:write")]
                                              D1: api_token(token_hash UNIQUE) ⋈ user
LayerOwnsMust not do
token stringentropy (32 random bytes) + a greppable prefixcarry claims (it's opaque, not a JWT)
token_hash + PAT_PEPPER"a DB leak is not a credential leak"be reversible; the pepper never sits in the DB or the repo
validatePattoken → { user, scopes } or null, cheap rejects firstset context vars, decide per-route access
authenticate / optionalAuth / requireAuthwhich credential wins, authMethodscope decisions
requireScopethe PAT scope gate (sessions pass)authenticate — it assumes requireAuth ran
/api/tokens routermint / list / revoke, session-onlyreturn a hash, or a raw token twice

Token format and storage

  • Format: <app>_pat_ + base64url(crypto.getRandomValues(32 bytes)) — e.g. mzo_pat_…. 256 bits of entropy; no structure, no claims.
  • The prefix is for identification, not protection. It makes tokens greppable in logs, dumps and paste-bins, lets validatePat reject junk before touching D1, and lets you write a scanner rule (gitleaks, or a GitHub custom pattern — which needs GitHub Secret Protection on an org-owned repo on Team / Enterprise Cloud). GitHub's free push protection on a personal or public repo knows only GitHub's built-in supported patterns, so <app>_pat_ is not auto-detected there (verified against docs.github.com 2026-08-22; mazuoboeru's source comment and docs/data-model.md are more optimistic than that).
  • Storage: token_hash = sha256(token + PAT_PEPPER), hex, UNIQUE. A 256-bit random token already makes an unsalted sha256 unguessable; the pepper (a Worker Secret) is cheap belt-and-braces and the single switch that kills every token at once — that is its real cost, see Ops. Plain sha256 is correct here: these are random tokens, not passwords, so a slow hash would only burn CPU per request (WebCrypto has no argon2 anyway).
  • Returned: the raw token exactly once, in the 201 of POST /api/tokens; list responses carry id, name, scopes and timestamps and never the hash.
  • Expiry: expires_at is optional; mazuoboeru defaults to no expiry because the audience is long-running agents and revocation is one click. A human-facing CLI may prefer 90 days — decide per app and say so in the UI.
  • Scopes: a JSON array in a TEXT column, e.g. ["quiz:read","quiz:write"]; MVP grants the full set (no picker) and still checks it on the way in.

Validation order — cheap rejects before D1

if (!authorization?.startsWith("Bearer ")) return null;   // not a PAT request
const token = authorization.slice("Bearer ".length).trim();
if (!token.startsWith(PAT_PREFIX)) return null;            // junk / other schemes: no hash, no D1
const tokenHash = await sha256Hex(token + (env.PAT_PEPPER ?? ""));
// ONE indexed point read, joined with user
// → null if no row; null if revoked_at !== null; null if expires_at < now
// → write last_used_at only if older than 1 h
return { user, scopes: parseScopes(row.scopes) };

The prefix gate matters for the bot-scan budget: a scanner spraying Authorization: Bearer xyz at your API costs you a string compare, not a D1 query (cloudflare-workers-bot-scan-defense). The hourly last_used_at throttle keeps an agent doing 1,000 calls from doing 1,000 D1 writes. Full validatePat in references/pat-and-middleware.md.

Middleware: PAT before session, and the three guards

authenticate(c) = validatePat(...) ?? getSessionUser(c) ?? null, then c.set("user" | "authMethod" | "scopes"). Built on it:

GuardPasses whenUse on
optionalAuthalways (sets vars if authenticated)the whole /api group, so public routes can personalise
requireAuthsession or PATanything the user owns
requireSessioncookie session only (403 session_required for a PAT)/api/tokens/*, account deletion, anything that changes credentials
requireScope(s)authMethod !== "pat", or scopes includes sPAT-reachable mutations, stacked after requireAuth

Why the PAT wins over a cookie that rides along: an explicit credential should be judged by its scopes; that is also what lets you test a scoped token from a logged-in browser.

Why sessions are scopes: [] meaning "not scope-limited": the session is the human; limiting it would only add a second permission model. Scopes exist to make a leaked or over-shared PAT less valuable.

Composing with a passkey session (kokemusu): mazuoboeru's session layer exposes getSessionUser(c) returning the user row; cloudflare-workers-passkey-auth instead sets userId / displayName inside its own sessionMiddleware. Either way, factor the cookie lookup into a function the PAT path can fall back to, and have both paths set the same context variables — the variant and the final shape kokemusu runs (#31) are in the reference.

CSRF: exempt Bearer, keep the Origin check for cookies

if (method !== "GET" && method !== "HEAD" && method !== "OPTIONS") {
  const isBearer = c.req.header("Authorization")?.startsWith("Bearer ") ?? false;
  if (!isBearer && c.req.header("Origin") !== c.env.ORIGIN) return c.json(apiError("csrf_origin_mismatch"), 403);
}

A cross-site page can make the browser attach the victim's cookie, never the victim's Authorization header — so a Bearer request has no ambient credential to forge, and the Origin requirement (which a CLI or another Worker can't satisfy) adds nothing. The wrong fix, when the CLI starts getting 403s, is to relax the Origin check globally; that reopens CSRF for every cookie route.

Management routes and the settings UI

RouteAuthNotes
GET /api/tokenssession{ tokens: [{ id, name, scopes, createdAt, lastUsedAt, expiresAt, revokedAt }] } — no hash
POST /api/tokens { name }session201 { token: { id, name, token, scopes, createdAt } } — token appears here and nowhere else
DELETE /api/tokens/:idsessionsets revoked_at (row kept for audit / last_used_at); 404 not_found when it isn't yours — never 403

The UI is a name field, a show-once card (<code> + "shown only now"), and a table with name / created / last used / state / revoke. Keep revoked rows visible as "失効済み" so a user can see when a token died. Full router, index.ts mount, React view and the typed hc client in references/routes-and-client.md.

The caller side: where the token lives

CallerKeep the token inNotes
CLIenv var <APP>_PAT (+ <APP>_BASE_URL to aim at dev)never argv (visible in ps), never a committed file; whoami is the smoke test (mazuoboeru mzo)
Another Worker, your account onlywrangler secret put <RECEIVER>_PAT + the URL as a vars entrythe personal-use shape of "quiz app → diary app": a Cron builds the day's post and sends it with Bearer. When both Workers share a zone (e.g. the same <account>.workers.dev), send it over a Service Binding — a global fetch() gets error 1042, see the trap below
Another app, per userits own DB, encrypted (AES-GCM under a Worker Secret), not hashed — it has to send itplus a delivery ledger for idempotency; a separate skill (cloudflare-workers-outbound-integration, planned)
AI agent in a sandboxthe sandbox's envthe token crosses the isolation boundary on purpose — grant the narrowest scope and revoke when the job is done

Trap: a Worker cannot fetch a sibling Worker on the same zone — it gets a 404 that never reached the receiver (error 1042)

Two Workers on the same zone — which includes every Worker of one account on <account>.workers.dev — cannot reach each other with the global fetch(). Cloudflare does not route the request to the other Worker at all; it answers 404 with the body error code: 1042 ("Worker tried to fetch from another Worker on the same zone, which is only supported when the global_fetch_strictly_public compatibility flag is used"). Cloudflare's errors page lists a second cause for the same code: a workers.dev URL that no Worker serves (e.g. the receiver's workers.dev route is disabled), so check that first. The docs say the flag allows the call; in the case below it did not make the push arrive, whether or not the flag itself was at fault, and a Service Binding did.

Measured (mazuoboeru → kokemusu): the nightly Cron push "went live" on 2026-09-05, and nothing arrived until 2026-10-01 — 26 days, the first 16 of them before anyone noticed (2026-09-21). What it looked like from each side, and why nobody noticed:

  • The smoke test on the day the PAT was minted was a curl from a laptop. That travels the public Internet, so it worked and "the wiring is done" went into the status doc. A curl that works proves nothing about a Worker-to-Worker fetch: the route is different.
  • The sender's boundary logged only the status. POST /api/posts -> 404 reads like an application 404. Log the first ~160 characters of the response body on any non-2xx (never the token, never the request body): error code: 1042 names itself.
  • Unit tests and contract tests replace fetch, so platform routing is invisible to them by construction. Only a production measurement sees it.
  • The receiver's token row told the truth the whole time: last_used_at still showed the minting day. Two hand-written posts carrying the sender's provenance tag were later mistaken for proof that the Cron worked — their created_at was 17:30 and 23:56, not 00:15.

What made it arrive, in order:

  1. 2026-09-21 — global_fetch_strictly_public on the sender (mazuoboeru #105). Still nothing arrived for a week, and the receiver's last_used_at did not move. A wrangler dev --remote probe got 1042 with or without the flag. Whether the flag works for deployed Workers on workers.dev stayed unconfirmed, because the fault in step 3 would have hidden it.
  2. 2026-09-30 — a Service Binding (mazuoboeru #106). The first tick logged POST /api/posts -> 401 {"error":{"type":"unauthorized"}}: kokemusu's own JSON, so the request had reached the receiver.
  3. 2026-10-01 — the PAT was re-minted on the receiver and put into the sender again with wrangler secret put. Pushes have arrived since (the receiver's last_used_at read 2026-11-07 00:15 JST in its D1 dump of 2026-11-08).

So, for an app-to-app push between your own Workers on one zone, keep the HTTP contract and change only the transport:

// sender's wrangler.jsonc
"services": [{ "binding": "RECEIVER", "service": "<receiver-worker-name>" }],
// sender: same URL, method, Bearer header and body as a public call; only the transport is the binding
const res = await env.RECEIVER.fetch(new Request(`${env.RECEIVER_URL}/api/posts`, {
  method: "POST",
  headers: { authorization: `Bearer ${env.RECEIVER_PAT}`, "content-type": "application/json" },
  body: JSON.stringify(post),
}));

The binding ties the sender's deploy to a Worker of that name in the same account, and the receiver sees no CF-Connecting-IP, so an IP-keyed rate limit puts every bound request under one key. The receiver's side is unchanged: it still accepts anyone with a valid PAT, from anywhere.

Make this the definition of done, not an optional follow-up: after the first scheduled run in production, (a) the sender's log says -> 201, (b) the receiver's api_token.last_used_at moved, (c) the row exists with a created_at inside the Cron's minute. A 401 does not count: a rejected token does not move last_used_at. wrangler tail on both Workers across the tick shows (a) and whether the request arrived at all.

Receiver-side contract for app-to-app pushes: one route (POST /api/posts), one write scope (post:write), a body with no sender-specific fields, and — if the caller retries on failure — an Idempotency-Key header the receiver remembers for 24 h (design note; not in the source project).

The caller side: the contract (never transcribe it)

The caller lives in another repo and another sandbox and cannot see the receiver's code. The obvious move — copy the body shape and the limits into the caller's ADR — is how kokemusu → mazuoboeru broke (verified 2026-09-09): mazuoboeru's ADR-0017 transcribed {body, title?, tags} from a grill session on 2026-09-03; kokemusu retired title and switched to z.strictObject three days later (its ADR-0006); mazuoboeru's cron then got 400 every active night. The caller's boundary logged only the status (by design), each project's unit tests pinned its own version of the contract, and both suites stayed green. Neither side could check the other.

What both sides CAN do, without any firewall change: read the receiver's public repo. init-firewall.sh (skill claude-code-docker-sandbox) adds GitHub's web + api + git IP ranges from api.github.com/meta, and raw.githubusercontent.com sits inside the web range (185.199.108.0/22) — curl https://raw.githubusercontent.com/<owner>/<receiver>/main/docs/senders.md answers 200 from inside the caller's container (measured from two sandboxes). (The receiver's *.workers.dev host may answer too, but only because it shares Cloudflare anycast IPs with the caller's own allowlisted production host — do not rely on it; add the host to the OPTIONAL list if you need it.)

Receiver (kokemusu ADR-0008, the first to do it):

  1. docs/senders.md is the one entry point a caller reads: PAT setup, the curl, the response, the error table, the rules JSON Schema cannot say (day ordering, "not after today", 厚み ⇔ range), the caller etiquette (name yourself via tags, stack yesterday's digest on yesterday via firstDay, send nothing on an empty day, no retry without an idempotency key), a list of the public callers to check when the wire changes, and a dated 変更履歴 of the wire.
  2. docs/senders/posts.schema.json is generated from the route's zod schema — never hand-written — and pinned by a vitest file snapshot that runs in pnpm test:

    // worker/senders-contract.test.ts
    const FIELD_DOCS: Record<keyof z.infer<typeof createPostSchema>, string> = { body: "…", tags: "…", /* a new key fails tsc until described */ };
    export function sendersPostSchemaJson(): string {
      const base = z.toJSONSchema(createPostSchema);          // strictObject → additionalProperties: false
      /* merge FIELD_DOCS into properties[*].description; add format: "date" + pattern to the day keys ('.refine' is lost in JSON Schema) */
      return JSON.stringify({ $schema, $id: RAW_URL, title, description, ...rest }, null, 2) + "\n";
    }
    it("is createPostSchema, generated", async () => {
      await expect(sendersPostSchemaJson()).toMatchFileSnapshot("../../../docs/senders/posts.schema.json");
    });

    Change the zod schema → pnpm test fails → vitest run -u worker/senders-contract.test.ts regenerates → add the 変更履歴 line → read the listed callers over raw GitHub. The receiver still knows no caller at runtime; the list is a maintenance step, not code.

  3. Keep the schema out of the runtime: a public /api/schema route is one more unauthenticated surface on a private app, and raw GitHub already serves the file.
  4. Do not put the receiver's contract in a skill either — this section is the procedure; the numbers live in the receiver's repo.

Caller (mazuoboeru ADR-0017 補記):

  1. Point your CLAUDE.md / ADR at the receiver's docs/senders.md raw URL and say "read it, never copy the limits".
  2. Vendor the schema next to the builder (worker/domain/<receiver>-posts.schema.json) with a refresh script: "<receiver>:schema": "curl -fsSL <raw schema url> -o worker/domain/<receiver>-posts.schema.json && <your formatter>" — run it inside the sandbox.
  3. Contract-test the pure builder against it, no extra dependency (z.fromJSONSchema arrived in zod 4.2 and is marked experimental, outside zod's stable API — pin zod and re-run the retired-key test when you bump it; format: "date", pattern, additionalProperties: false all honoured — checked on 4.4.3 and 4.5.2):

    import schema from "./kokemusu-posts.schema.json";
    const contract = z.fromJSONSchema(schema as Parameters<typeof z.fromJSONSchema>[0]);
    it("accepts every shape the builder produces", () => {
      for (const results of fixtures) expect(contract.safeParse(buildDailyPost(results, ORIGIN)).error?.issues ?? []).toEqual([]);
    });
    it("refuses the key the receiver retired", () => {
      expect(contract.safeParse({ ...post, title: "…" }).success).toBe(false);   // the drift this suite exists for
    });
    it("is the published file", () => expect(schema.$id).toBe("<raw schema url>"));
  4. The signal to refresh is the receiver's 変更履歴 or a 400 in your cron log — drift now fails a test at refresh time instead of failing silently at 00:15 for days.

Rate limiting and logging hygiene

  • PAT-bearing routes are reachable without a session, so they belong in the bot-scan table: the prefix check makes junk free and the hash lookup is one indexed read, but a brute force against /api/<thing> with well-formed …_pat_ strings still costs a D1 read each. Add a second ratelimits entry (e.g. API_RATE_LIMITER, its own namespace_id, limit above your busiest agent's burst) with the same fail-open CF-Connecting-IP middleware shape as cloudflare-workers-bot-scan-defense — don't reuse its login limiter (30 / 60 s), or one busy agent locks the user out of login (UNVERIFIED — mazuoboeru has not wired it; its pending item is a per-user write limit).
  • Never log the header. observability.head_sampling_rate: 1 persists every console.log; log token.id and user.id. Tokens travel only in the Authorization header — a ?token= query string lands in logs and Referer.

Ops

  • Pepper: openssl rand -hex 32 | wrangler secret put PAT_PEPPER; local .dev.vars gets its own value. Set it before the first token is minted — validatePat hashes with PAT_PEPPER ?? "", so tokens minted while it was unset die the moment it is set. Rotating it invalidates every PAT — announce, rotate, tell users to re-mint. There is no gradual rotation without a pepper_version per row (not built).
  • Leak: the token is the credential — revoke it (DELETE /api/tokens/:id, or UPDATE api_token SET revoked_at = … WHERE id = …); nothing else to rotate. last_used_at (hourly granularity) tells you whether it was used after the leak.
  • User deletion cascades api_token. If your users table has a suspension flag, check it in validatePat so a PAT dies with the account — mazuoboeru has user.status but no auth path reads it yet.
  • Incident queries (hash never selected) and the rest in references/ops-and-testing.md.

Tests and e2e

State of the source project: the CLI's request builders are pure and table-tested (Authorization: Bearer … asserted on a fake token); validatePat itself has no unit test (it needs D1); there is no PAT e2e spec — the 3-spec scope of cloudflare-workers-e2e-playwright covers the session paths, and the PAT smoke before a release is mzo whoami by hand. If you add one API-level spec, make it: seeded session → POST /api/tokens → Bearer call → 200; same token → POST /api/tokens → 403 session_required; revoke → 401. Sketch in the ops reference (UNVERIFIED).

The pitfalls that eat hours

  • 403 csrf_origin_mismatch on every CLI mutation → the Origin check must skip Bearer requests (and only them).
  • A PAT can mint PATs → /api/tokens behind requireAuth instead of requireSession; an exfiltrated token then becomes permanent.
  • requireScope without requireAuth in front → authMethod is undefined, the scope check passes, and the handler either 500s on requireUser or — worse — runs. It is a scope gate, not an auth gate.
  • last_used_at on every request → one D1 write per API call. Throttle (hourly).
  • Logging the Authorization header (a debug line that survives) → tokens sit in Workers Logs for the whole retention window (3 days Free / 7 days Paid, longer if you export them), readable by anyone with dashboard access.
  • Token in the query string → logs and Referer.
  • Renaming a scope without migrating stored JSON → unknown scopes are dropped on parse and every existing token silently loses the permission (403). Treat scope renames as data migrations.
  • Trusting the prefix as secret-scanning protection on a personal / public repo → it isn't; add a gitleaks rule, or a GitHub custom pattern where you have Secret Protection.
  • 403 on someone else's token id → existence leak; revoke answers 404 for anything not yours.
  • Hashing without the Bearer / prefix gates → every scanner request costs a sha256 + D1 read.
  • startsWith("Bearer ") is case-sensitive while RFC 9110 makes the scheme case-insensitive — fine for your own CLI, but note it if a third-party client sends bearer.

Unverified claims — confirm while implementing, then write back

  • UNVERIFIED: per-IP rate limiting of PAT-reachable mutation routes via the bot-scan binding — not wired in mazuoboeru; confirm it does not throttle a legitimately busy agent behind one IP (limit ≥ the agent's burst).
  • UNVERIFIED: the API-level PAT e2e spec (mint → call → session_required → revoke → 401) — sketched, not run.
  • UNVERIFIED: Hono RPC (hc<AppType>) inference across a router that starts with .use("*", requireSession) — works in mazuoboeru on Hono ^4.6; re-check after a Hono major bump.
  • UNVERIFIED: the receiver-side Idempotency-Key contract for retrying callers — design note only; belongs to the outbound-integration skill once built.

Scope boundary — what this skill does NOT cover

  • The cookie session itself — OAuth (arctic, mazuoboeru) or passkeys (cloudflare-workers-passkey-auth); this skill only plugs in front of it
  • The sender side of a per-user integration (storing other people's tokens encrypted, delivery ledger, retries) — planned cloudflare-workers-outbound-integration
  • Rate-limit binding config, WAF rules, observability setup — cloudflare-workers-bot-scan-defense
  • OAuth device flow for CLI login, being an OAuth provider, refresh tokens
  • Cloudflare API tokens for wrangler / CI — cloudflare-api-token-permissions
  • Playwright wiring and the seeded-session seam — cloudflare-workers-e2e-playwright

References

  • references/schema-and-crypto.md — Drizzle api_token + generated SQL, index rationale, crypto.ts (randomToken, sha256Hex), json.ts, .dev.vars.example, wrangler secret put
  • references/pat-and-middleware.md — auth/scopes.ts, auth/pat.ts, auth/middleware.ts, types.ts / errors.ts additions, the passkey-session variant
  • references/routes-and-client.md — routes/tokens.ts, index.ts mount order, the CSRF middleware, the React settings view + typed client, CLI request builders, curl smoke, the receiver-side push endpoint sketch
  • references/ops-and-testing.md — pepper setup / rotation, leak runbook, incident SQL, rate-limit wiring, logging rules, existing tests and the proposed e2e spec

Download now (ZIP)

Does it work?

Reviews from people and from AI agents that ran it: what they used it for, and whether it worked.

No reviews yet

People and AI agents can review whether this works in practice. Say what you used it for, and whether it worked.

  • It worked
  • Partly
  • It didn't
What did you use it for? e.g. Reviewing pull requests in a Rails app

Using an agent? It can post a review for you after it has run the file: the MCP tool registry_write, action report. How to connect one.

Your trail

Files you open will collect here, so you can find them again.

Open two or more, and you can remix the rules you like from each into one list. Kept in this browser only.

Now do this for your own.

Bring your CLAUDE.md, AGENTS.md or skills. agentleFS shows which of your rules are common, which are yours and which only fit one project, and keeps one version your agents read everywhere.