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 planned is kokemusu (POST /api/posts behind post:write).
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
Worker-to-Worker calls inside one Cloudflare account — use a service binding, no secret at all
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)
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
✗
only within one account — a self-hosted receiver in another account can't be bound
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.
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
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 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:
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 is sketched in the reference (UNVERIFIED).
CSRF: exempt Bearer, keep the Origin check for cookies
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.
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 fetches with Bearer
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
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.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.
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.
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.
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.md raw 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 (zod ≥ 4.1 has z.fromJSONSchema; 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>"));
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 secondratelimits 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: the passkey-session composition (authenticate falling back to cloudflare-workers-passkey-auth's cookie lookup, both paths setting userId / displayName) has not run — kokemusu is the first; record the final Variables shape in the middleware reference.
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
1---2name: cloudflare-workers-pat-bearer-auth3description: Cloudflare Workers PAT (Bearer) auth for machine callers4---56# Cloudflare Workers PAT (Bearer) auth for machine callers78Get 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.910Extracted 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 planned is kokemusu (`POST /api/posts` behind `post:write`).1112**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.1314## When to use this skill1516- A CLI or AI agent must create / update things as the user (mazuoboeru: AI-driven quiz mass-production through `mzo`)17- 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")18- Symptoms in an existing app:19 - every non-GET call from the CLI returns `403 csrf_origin_mismatch` → the CSRF Origin check is not exempting Bearer20 - a token can call `POST /api/tokens` and mint more tokens → management route is behind `requireAuth` instead of `requireSession`21 - revoking a token logs the user out (or vice versa) → sessions and PATs share a table22 - a D1 dump would hand out live credentials → raw tokens stored23 - one busy agent writes `last_used_at` on every request → no throttle2425Do **not** use for:26- Third-party developer apps that need a consent screen, refresh tokens or per-app client ids — that is being an OAuth *provider*, a different design27- Worker-to-Worker calls inside **one** Cloudflare account — use a service binding, no secret at all28- Anonymous API keys with no user behind them (metrics, public read APIs) — no `user_id`, no scopes; a different table29- **Cloudflare API tokens** (`wrangler deploy`, CI) — unrelated despite the name; see `cloudflare-api-token-permissions`3031## Decision: why a PAT (and when not)3233| Option | Per-user machine access | Why it lost / won (source) |34|---|---|---|35| **PAT (Bearer)** | ✓ | ~150 LOC, one table, works from `curl`; the user copies one secret by hand (ADR-0001) |36| OAuth Device Authorization Grant | later | nicer CLI login, but +200 LOC and a polling endpoint; can be layered on top of PATs (ADR-0001) |37| Cloudflare Access service token | ✗ | a machine identity, not a user; authz lives in the dashboard |38| Service binding | ✗ | only within one account — a self-hosted receiver in *another* account can't be bound |39| 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 |4041Constraints 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.4243## Deliverables (completion criteria)4445- [ ] `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](references/schema-and-crypto.md))46- [ ] `PAT_PEPPER` set with `wrangler secret put` in prod, in `.dev.vars` locally, listed (key only) in `.dev.vars.example`47- [ ] Token = `<app>_pat_` + base64url(32 random bytes); the raw value is returned **once**, from `POST /api/tokens` (201), never again48- [ ] `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 hourly49- [ ] `authenticate()` tries the PAT **before** the session and records `c.var.authMethod` as `"pat" | "session"`50- [ ] `/api/tokens/*` sits behind `requireSession` → a PAT gets `403 session_required`51- [ ] PAT-reachable mutations stack `requireAuth, requireScope("<resource>:write")` — in that order; sessions pass every scope52- [ ] `SCOPES` is a `const` tuple; stored scopes unknown to the current build are dropped on parse53- [ ] The CSRF Origin check skips requests that carry `Authorization: Bearer`54- [ ] No `console.*` of the `Authorization` header or a token anywhere (Workers Observability persists it); log the token `id`55- [ ] Settings UI: name → create → show-once card → list (name / created / last used / state) → revoke56- [ ] Caller docs name the env var (`<APP>_PAT`), the base-URL override and a `whoami` smoke command57- [ ] 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*)58- [ ] Every `UNVERIFIED:` bullet below checked on the real app and written back5960## Architecture in one screen6162```63caller: CLI env var / another Worker's secret / agent sandbox env64 └─ Authorization: Bearer <app>_pat_… ──▶ Worker (Hono)65 ├─ securityHeaders66 ├─ csrf non-GET: Origin === ORIGIN — SKIPPED for Bearer67 ├─ optionalAuth PAT first → cookie session → c.var.{user,authMethod,scopes}68 ├─ /api/tokens/* requireSession (cookie only: a PAT can't mint PATs)69 └─ domain routes requireAuth [, requireScope("x:write")]70 D1: api_token(token_hash UNIQUE) ⋈ user71```7273| Layer | Owns | Must not do |74|---|---|---|75| token string | entropy (32 random bytes) + a greppable prefix | carry claims (it's opaque, not a JWT) |76| `token_hash` + `PAT_PEPPER` | "a DB leak is not a credential leak" | be reversible; the pepper never sits in the DB or the repo |77| `validatePat` | token → `{ user, scopes }` or `null`, cheap rejects first | set context vars, decide per-route access |78| `authenticate` / `optionalAuth` / `requireAuth` | which credential wins, `authMethod` | scope decisions |79| `requireScope` | the PAT scope gate (sessions pass) | authenticate — it assumes `requireAuth` ran |80| `/api/tokens` router | mint / list / revoke, session-only | return a hash, or a raw token twice |8182## Token format and storage8384- **Format**: `<app>_pat_` + `base64url(crypto.getRandomValues(32 bytes))` — e.g. `mzo_pat_…`. 256 bits of entropy; no structure, no claims.85- **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).86- **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).87- **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.88- **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.89- **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.9091## Validation order — cheap rejects before D19293```ts94if (!authorization?.startsWith("Bearer ")) return null; // not a PAT request95const token = authorization.slice("Bearer ".length).trim();96if (!token.startsWith(PAT_PREFIX)) return null; // junk / other schemes: no hash, no D197const tokenHash = await sha256Hex(token + (env.PAT_PEPPER ?? ""));98// ONE indexed point read, joined with user99// → null if no row; null if revoked_at !== null; null if expires_at < now100// → write last_used_at only if older than 1 h101return { user, scopes: parseScopes(row.scopes) };102```103104The 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](references/pat-and-middleware.md).105106## Middleware: PAT before session, and the three guards107108`authenticate(c)` = `validatePat(...)` ?? `getSessionUser(c)` ?? `null`, then `c.set("user" | "authMethod" | "scopes")`. Built on it:109110| Guard | Passes when | Use on |111|---|---|---|112| `optionalAuth` | always (sets vars if authenticated) | the whole `/api` group, so public routes can personalise |113| `requireAuth` | session **or** PAT | anything the user owns |114| `requireSession` | cookie session only (`403 session_required` for a PAT) | `/api/tokens/*`, account deletion, anything that changes *credentials* |115| `requireScope(s)` | `authMethod !== "pat"`, or `scopes` includes `s` | PAT-reachable mutations, **stacked after `requireAuth`** |116117**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.118119**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.120121**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 is sketched in the reference (UNVERIFIED).122123## CSRF: exempt Bearer, keep the Origin check for cookies124125```ts126if (method !== "GET" && method !== "HEAD" && method !== "OPTIONS") {127 const isBearer = c.req.header("Authorization")?.startsWith("Bearer ") ?? false;128 if (!isBearer && c.req.header("Origin") !== c.env.ORIGIN) return c.json(apiError("csrf_origin_mismatch"), 403);129}130```131132A 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.133134## Management routes and the settings UI135136| Route | Auth | Notes |137|---|---|---|138| `GET /api/tokens` | session | `{ tokens: [{ id, name, scopes, createdAt, lastUsedAt, expiresAt, revokedAt }] }` — no hash |139| `POST /api/tokens` `{ name }` | session | `201 { token: { id, name, token, scopes, createdAt } }` — `token` appears here and nowhere else |140| `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 |141142The 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](references/routes-and-client.md).143144## The caller side: where the token lives145146| Caller | Keep the token in | Notes |147|---|---|---|148| 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`) |149| 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 `fetch`es with Bearer |150| 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) |151| 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 |152153Receiver-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).154155## The caller side: the contract (never transcribe it)156157The 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.158159What 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.)160161**Receiver** (kokemusu ADR-0008, the first to do it):1621631. `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.1642. `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`:165166 ```ts167 // worker/senders-contract.test.ts168 const FIELD_DOCS: Record<keyof z.infer<typeof createPostSchema>, string> = { body: "…", tags: "…", /* a new key fails tsc until described */ };169 export function sendersPostSchemaJson(): string {170 const base = z.toJSONSchema(createPostSchema); // strictObject → additionalProperties: false171 /* merge FIELD_DOCS into properties[*].description; add format: "date" + pattern to the day keys ('.refine' is lost in JSON Schema) */172 return JSON.stringify({ $schema, $id: RAW_URL, title, description, ...rest }, null, 2) + "\n";173 }174 it("is createPostSchema, generated", async () => {175 await expect(sendersPostSchemaJson()).toMatchFileSnapshot("../../../docs/senders/posts.schema.json");176 });177 ```178179 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.1803. 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.1814. Do not put the receiver's contract in a skill either — this section is the *procedure*; the numbers live in the receiver's repo.182183**Caller** (mazuoboeru ADR-0017 補記):1841851. Point your CLAUDE.md / ADR at the receiver's `docs/senders.md` raw URL and say "read it, never copy the limits".1862. 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.1873. Contract-test the pure builder against it, no extra dependency (zod ≥ 4.1 has `z.fromJSONSchema`; `format: "date"`, `pattern`, `additionalProperties: false` all honoured — checked on 4.4.3 and 4.5.2):188189 ```ts190 import schema from "./kokemusu-posts.schema.json";191 const contract = z.fromJSONSchema(schema as Parameters<typeof z.fromJSONSchema>[0]);192 it("accepts every shape the builder produces", () => {193 for (const results of fixtures) expect(contract.safeParse(buildDailyPost(results, ORIGIN)).error?.issues ?? []).toEqual([]);194 });195 it("refuses the key the receiver retired", () => {196 expect(contract.safeParse({ ...post, title: "…" }).success).toBe(false); // the drift this suite exists for197 });198 it("is the published file", () => expect(schema.$id).toBe("<raw schema url>"));199 ```2002014. 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.202203## Rate limiting and logging hygiene204205- 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).206- **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`.207208## Ops209210- **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).211- **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.212- **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.213- Incident queries (hash never selected) and the rest in [references/ops-and-testing.md](references/ops-and-testing.md).214215## Tests and e2e216217State 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).218219## The pitfalls that eat hours220221- **`403 csrf_origin_mismatch` on every CLI mutation** → the Origin check must skip Bearer requests (and only them).222- **A PAT can mint PATs** → `/api/tokens` behind `requireAuth` instead of `requireSession`; an exfiltrated token then becomes permanent.223- **`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.224- **`last_used_at` on every request** → one D1 write per API call. Throttle (hourly).225- **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.226- **Token in the query string** → logs and `Referer`.227- **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.228- **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.229- **403 on someone else's token id** → existence leak; revoke answers `404` for anything not yours.230- **Hashing without the `Bearer ` / prefix gates** → every scanner request costs a sha256 + D1 read.231- **`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`.232233## Unverified claims — confirm while implementing, then write back234235- UNVERIFIED: the passkey-session composition (`authenticate` falling back to `cloudflare-workers-passkey-auth`'s cookie lookup, both paths setting `userId` / `displayName`) has not run — kokemusu is the first; record the final `Variables` shape in the middleware reference.236- 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).237- UNVERIFIED: the API-level PAT e2e spec (mint → call → `session_required` → revoke → 401) — sketched, not run.238- 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.239- UNVERIFIED: the receiver-side `Idempotency-Key` contract for retrying callers — design note only; belongs to the outbound-integration skill once built.240241## Scope boundary — what this skill does NOT cover242243- The cookie session itself — OAuth (`arctic`, mazuoboeru) or passkeys (`cloudflare-workers-passkey-auth`); this skill only plugs in front of it244- The **sender** side of a per-user integration (storing other people's tokens encrypted, delivery ledger, retries) — planned `cloudflare-workers-outbound-integration`245- Rate-limit binding config, WAF rules, observability setup — `cloudflare-workers-bot-scan-defense`246- OAuth device flow for CLI login, being an OAuth provider, refresh tokens247- **Cloudflare API tokens** for `wrangler` / CI — `cloudflare-api-token-permissions`248- Playwright wiring and the seeded-session seam — `cloudflare-workers-e2e-playwright`249250## References251252- [references/schema-and-crypto.md](references/schema-and-crypto.md) — Drizzle `api_token` + generated SQL, index rationale, `crypto.ts` (`randomToken`, `sha256Hex`), `json.ts`, `.dev.vars.example`, `wrangler secret put`253- [references/pat-and-middleware.md](references/pat-and-middleware.md) — `auth/scopes.ts`, `auth/pat.ts`, `auth/middleware.ts`, `types.ts` / `errors.ts` additions, the passkey-session variant254- [references/routes-and-client.md](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 sketch255- [references/ops-and-testing.md](references/ops-and-testing.md) — pepper setup / rotation, leak runbook, incident SQL, rate-limit wiring, logging rules, existing tests and the proposed e2e spec
Run npx skillmds@latest add okayus/cloudflare-workers-pat-bearer-auth in your terminal (requires Node.js), paste this page's agent-chat prompt into Claude, Cursor, or any MCP-connected agent, or download the SKILL.md file and copy it into your agent's skills directory.
Cloudflare Workers PAT (Bearer) auth for machine callers It is listed under Coding & Dev Tools on SkillMD.
This skill has not completed SkillMD's automated safety review yet. SkillMD never runs a skill's scripts for you; review the SKILL.md before installing.
This skill is tagged as working with Claude Code, Claude.ai, OpenAI Codex. SKILL.md is an open format, so most agents that read a skills directory can load it too.
Yes. Installing skills from SkillMD is free, and the skill stays under its author's original license.
okayus (@okayus) published this skill. Their other Agent Skills are listed on their SkillMD profile.