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
In this repository23 other files it gives its agents.
- skills/vercel-react-best-practices/AGENTS.mdAGENTS.md
- agent-status-hubSkill
- claude-code-docker-sandboxSkill
- cloudflare-api-token-permissionsSkill
- cloudflare-cron-to-discordSkill
- cloudflare-d1-drizzle-migrationSkill
- cloudflare-d1-keyless-host-backupSkill
- cloudflare-d1-weekly-backup-via-prSkill
- cloudflare-mcp-claude-toolingSkill
- cloudflare-r2-private-image-uploadSkill
- cloudflare-workers-bot-scan-defenseSkill
- cloudflare-workers-builds-keyless-deploySkill
- cloudflare-workers-deploy-skeletonSkill
- cloudflare-workers-e2e-playwrightSkill
- cloudflare-workers-host-synthetic-checkSkill
- cloudflare-workers-passkey-authSkill
- cloudflare-workers-space-membership-inviteSkill
- cloudflare-workflows-for-long-tasksSkill
- playwright-e2e-in-docker-sandboxSkill
- sandboxed-agent-github-token-via-1passwordSkill
- sandboxed-agent-git-relaySkill
- snr-writingSkill
- vercel-react-best-practicesSkill
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
- Very specific: line 24
every non-GET call from the CLI returns 403 csrf_origin_mismatch → the CSRF Origin check is not exempting Bearer
- Distinctive: line 30
Do not use for:
- Very specific: line 222
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 …
- Distinctive: line 223
Do not put the receiver's contract in a skill either — this section is the *procedure*.
≈ “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.
Files
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/tokensand mint more tokens → management route is behindrequireAuthinstead ofrequireSession - 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_aton every request → no throttle
- every non-GET call from the CLI returns
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; seecloudflare-api-token-permissions
Decision: why a PAT (and when not)
| Option | Per-user machine access | Why 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 Grant | later | nicer 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_PEPPERset withwrangler secret putin prod, in.dev.varslocally, listed (key only) in.dev.vars.example- Token =
<app>_pat_+ base64url(32 random bytes); the raw value is returned once, fromPOST /api/tokens(201), never again validatePatrejects in this order: noBearer→ wrong prefix → hash miss →revoked_at→expires_at; one indexed lookup joined withuser;last_used_atwritten at most hourlyauthenticate()tries the PAT before the session and recordsc.var.authMethodas"pat" | "session"/api/tokens/*sits behindrequireSession→ a PAT gets403 session_required- PAT-reachable mutations stack
requireAuth, requireScope("<resource>:write")— in that order; sessions pass every scope SCOPESis aconsttuple; stored scopes unknown to the current build are dropped on parse- The CSRF Origin check skips requests that carry
Authorization: Bearer - No
console.*of theAuthorizationheader or a token anywhere (Workers Observability persists it); log the tokenid - 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 awhoamismoke 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 inpnpm test; every caller vendors that schema and contract-tests its builder withz.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| Layer | Owns | Must not do |
| token string | entropy (32 random bytes) + a greppable prefix | carry 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 |
validatePat | token → { user, scopes } or null, cheap rejects first | set context vars, decide per-route access |
authenticate / optionalAuth / requireAuth | which credential wins, authMethod | scope decisions |
requireScope | the PAT scope gate (sessions pass) | authenticate — it assumes requireAuth ran |
/api/tokens router | mint / list / revoke, session-only | return 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
validatePatreject 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 anddocs/data-model.mdare 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
201ofPOST /api/tokens; list responses carryid,name,scopesand timestamps and never the hash. - Expiry:
expires_atis 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:
| Guard | Passes when | Use on |
optionalAuth | always (sets vars if authenticated) | the whole /api group, so public routes can personalise |
requireAuth | session or PAT | anything the user owns |
requireSession | cookie session only (403 session_required for a PAT) | /api/tokens/*, account deletion, anything that changes credentials |
requireScope(s) | authMethod !== "pat", or scopes includes s | PAT-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
| Route | Auth | Notes |
GET /api/tokens | session | { tokens: [{ id, name, scopes, createdAt, lastUsedAt, expiresAt, revokedAt }] } — no hash |
POST /api/tokens { name } | session | 201 { token: { id, name, token, scopes, createdAt } } — token appears here and nowhere else |
DELETE /api/tokens/:id | session | sets 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
| Caller | Keep the token in | Notes |
| CLI | env 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 only | wrangler secret put <RECEIVER>_PAT + the URL as a vars entry | the 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 user | its own DB, encrypted (AES-GCM under a Worker Secret), not hashed — it has to send it | plus a delivery ledger for idempotency; a separate skill (cloudflare-workers-outbound-integration, planned) |
| AI agent in a sandbox | the sandbox's env | the 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
curlfrom a laptop. That travels the public Internet, so it worked and "the wiring is done" went into the status doc. Acurlthat works proves nothing about a Worker-to-Workerfetch: the route is different. - The sender's boundary logged only the status.
POST /api/posts -> 404reads 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: 1042names 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_atstill showed the minting day. Two hand-written posts carrying the sender's provenance tag were later mistaken for proof that the Cron worked — theircreated_atwas 17:30 and 23:56, not 00:15.
What made it arrive, in order:
- 2026-09-21 —
global_fetch_strictly_publicon the sender (mazuoboeru #105). Still nothing arrived for a week, and the receiver'slast_used_atdid not move. Awrangler dev --remoteprobe 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. - 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. - 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'slast_used_atread 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):
docs/senders.mdis the one entry point a caller reads: PAT setup, thecurl, 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 viafirstDay, 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.docs/senders/posts.schema.jsonis generated from the route's zod schema — never hand-written — and pinned by a vitest file snapshot that runs inpnpm 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 testfails →vitest run -u worker/senders-contract.test.tsregenerates → 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.- Keep the schema out of the runtime: a public
/api/schemaroute is one more unauthenticated surface on a private app, and raw GitHub already serves the file. - 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 補記):
- Point your CLAUDE.md / ADR at the receiver's
docs/senders.mdraw URL and say "read it, never copy the limits". - 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. Contract-test the pure builder against it, no extra dependency (
z.fromJSONSchemaarrived 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: falseall 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>"));- The signal to refresh is the receiver's 変更履歴 or a
400in 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 secondratelimitsentry (e.g.API_RATE_LIMITER, its ownnamespace_id, limit above your busiest agent's burst) with the same fail-openCF-Connecting-IPmiddleware shape ascloudflare-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: 1persists everyconsole.log; logtoken.idanduser.id. Tokens travel only in theAuthorizationheader — a?token=query string lands in logs andReferer.
Ops
- Pepper:
openssl rand -hex 32 | wrangler secret put PAT_PEPPER; local.dev.varsgets its own value. Set it before the first token is minted —validatePathashes withPAT_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 apepper_versionper row (not built). - Leak: the token is the credential — revoke it (
DELETE /api/tokens/:id, orUPDATE 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 invalidatePatso a PAT dies with the account — mazuoboeru hasuser.statusbut 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_mismatchon every CLI mutation → the Origin check must skip Bearer requests (and only them).- A PAT can mint PATs →
/api/tokensbehindrequireAuthinstead ofrequireSession; an exfiltrated token then becomes permanent. requireScopewithoutrequireAuthin front →authMethodis undefined, the scope check passes, and the handler either 500s onrequireUseror — worse — runs. It is a scope gate, not an auth gate.last_used_aton every request → one D1 write per API call. Throttle (hourly).- Logging the
Authorizationheader (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
404for 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 sendsbearer.
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-Keycontract 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.tsadditions, the passkey-session variant - references/routes-and-client.md —
routes/tokens.ts,index.tsmount order, the CSRF middleware, the React settings view + typed client, CLI request builders,curlsmoke, 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
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
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.

