Search

Sign in to launch Copilot/Codex from the palette.

Back to prompts

Sign in with Cloudflare Funnel

Public apps where people sign in with their own Cloudflare account and pay for their own inference. OAuth setup, invariants, pitfalls, and a checklist.

Operating Docs Updated Oct 15, 2026 ~2.5k tokens
1.3% of 200k
# Sign in with Cloudflare Funnel

Put this at the start of a session when you build a public app that people sign in to with their own Cloudflare account. Every model call is billed to the person who made it, not to you.

Reference app: [coil](https://coil.coey.dev). Stack: foldkit + Tailwind client, one Worker, Durable Objects. This goes with [Repo Shape](/prompts/repo-shape).

---

## The funnel: a `*.coey.dev` app with Sign in with Cloudflare

### Why this shape

A public app should cost the host nothing per user for inference, and it should show the Cloudflare Developer Platform working. Each person signs in with their own Cloudflare account. The app runs on the host's Workers, but **every model call is billed to the person who made it**.

### The flow

```text
visitor -> https://<app>.coey.dev            (public landing, no gate)
        -> "Sign in with Cloudflare"
        -> dash.cloudflare.com OAuth consent  (person picks ONE account)
        -> /oauth/callback                    (code + PKCE exchange)
        -> grant saved in a Durable Object    (account, gateway, tokens)
        -> /oauth/gateway picker if more than one usable gateway
        -> app; each model call uses the person's token on the person's account
```

### Invariants (do not break these)

1. **Auth mode is set per instance:** `COIL_AUTH=cloudflare` or `access`. Internal instances use Access. The `*.coey.dev` instances use Cloudflare OAuth.
2. **No fallback to host inference for signed-in users.** A `cf:` user's Workers AI and AI Gateway calls go to `accounts/<their account>/...` with their bearer token. If that fails, show the error; never quietly bill the host.
   - **The one allowed exception:** an instance-wide model gateway that the operator sets up on purpose (`COIL_MODEL_GATEWAY_URL` plus `MODEL_GATEWAY_TOKEN`). It is checked first for `gateway/...` models and uses the **operator's** identity. Do this only when the operator chooses to pay for those models.
3. **Read the account route fresh on every call.** Account ID, gateway and token come from the Directory Durable Object at call time, not from a copy the thread keeps. Then a gateway switch applies on the next call, and an empty account ID can never be sent.
4. **Only gateways with authentication on.** A gateway with auth off ignores the user's token and returns `x-api-key header is required`. The picker greys these out and the server rejects them.
5. **Show whose bill it is.** The UI shows the account and gateway in use, and lets the person switch (sidebar button: `/oauth/gateway`).
6. **Secrets never go to the browser and never get printed.** The OAuth client secret and the session secret are Worker secrets. Tokens live only in Durable Object storage.

### OAuth client (one per app, made by hand in the dashboard)

Go to Manage Account, then OAuth clients, on the account that owns the zone.

| Field | Value |
|---|---|
| Response type | Code |
| Grants | Authorization Code, Refresh Token |
| Token auth method | Client Secret Basic |
| Redirect URLs | `https://<app>.coey.dev/oauth/callback` and `http://localhost:<port>/oauth/callback` |
| Client URL | `https://<app>.coey.dev` |
| Scopes | `offline_access user-details.read account-settings.read ai.read ai.write aig.read aig.run` |
| Do NOT request | `openid`: it is rejected for these clients |
| Publisher | TXT record `cloudflare_oauth_client_publisher=<value>` on `<app>.coey.dev`, then make the client **Public** |

- The **client secret is shown once.** Pipe it straight into `wrangler secret put OAUTH_CLIENT_SECRET`.
- No OAuth scope and no wrangler or cf token can create OAuth clients. It is a dashboard step every time.

### Worker config

- **Secrets:** `OAUTH_CLIENT_SECRET`, plus `SESSION_SECRET` (at least 32 characters: `openssl rand -hex 32`).
- **Vars:** `OAUTH_CLIENT_ID`, `COIL_AUTH=cloudflare`, and optionally `COIL_USER_GATEWAY` (the preferred gateway name).
- **Always pass `CLOUDFLARE_ACCOUNT_ID=<id>`** to `wrangler secret` commands. The wrangler login covers several accounts, and the local jsonc files carry no account ID.
- **Instance config** lives in gitignored `deploy/<instance>.conf`. Copy `deploy/example.conf`. `wrangler.*.local.jsonc` is generated and gitignored.
- **Deploy:** `bash scripts/ship.sh deploy/<instance>.conf`. It runs biome, checks that the secrets exist, deploys, and runs an edge check.

### Session and cookies

- Choose cookies by scheme (`cookieJar(requestUrl)`): `__Host-` names plus `Secure` on https only. WebKit drops `Secure` cookies on `http://localhost`.
- The session lasts 30 days. The login flow cookie lasts 10 minutes. Sign out is POST `/oauth/logout`, which revokes the token and drops the grant.
- Use **one Directory Durable Object name everywhere** (coil: `DIRECTORY_NAME='all'` in `src/directory-name.ts`). Two names means grants written in one place are never read in the other.

### Picking the account and gateway

- At consent, the person may tick several accounts. The grant uses the **first** account the token can see. **Tell people to tick one account.** An account picker after sign-in is the planned fix.
- **Gateway choice** (`pickGateway`): `COIL_USER_GATEWAY` if it has auth on, else `default` if it has auth on, else the first gateway with auth on. If none qualify, explain how to turn auth on for a gateway. Do not just fail.
- If more than one gateway qualifies, send the person to `/oauth/gateway` after sign-in.

### Model routing for `cf:` users

- **Workers AI models (`@cf/...`):** `api.cloudflare.com/client/v4/accounts/<acct>/ai/run/...` (or through their gateway) with `authorization: Bearer <user token>`.
- **Provider models (`gateway/...`, such as Opus):** `gateway.ai.cloudflare.com/v1/<acct>/<gateway>/anthropic` with `cf-aig-authorization: Bearer <user token>`. The person's **AI Gateway unified billing credits** pay for these. With no credits, the call returns `402 Insufficient unified billing credits` (code 2021). That is the person's account state, not an app bug.
- Rewrite Anthropic "thinking" to adaptive (coil: `rewriteBody` in `src/gateway.ts`) on **both** the host and the user path.

---

## Pitfalls seen in practice

| Symptom | Cause | Fix |
|---|---|---|
| `402 Insufficient unified billing credits` on Opus | The account's unified billing credits are empty. Every gateway on that account fails the same way. | Credits on that account, or a deliberate instance-wide model gateway (invariant 2). Don't keep switching gateways. |
| `401` straight after sign-in | The thread used a copy of the grant that was missing or empty (blank account ID) | Read the route fresh per call (invariant 3) |
| `x-api-key header is required` | The gateway has authentication off | Only allow gateways with auth on |
| Signed in, but threads can't see the grant | Two different Directory Durable Object names | One `DIRECTORY_NAME` constant |
| Cookies missing on localhost | `__Host-` or `Secure` cookies over http | Choose cookies by scheme |
| `openid` rejected at consent | The client may not request it | Leave `openid` out of the scopes |
| Wrong account billed | Person ticked several accounts; the grant took the first | Tell people to tick one; build the account picker |
| `wrangler secret put` lands on the wrong account | No `CLOUDFLARE_ACCOUNT_ID` | Always set it |
| Testing a gateway with the cf CLI login token returns 401 or 402 | That token isn't the user's OAuth token, and the cf CLI can't request `aig.run` | Test through the app's own receipt (`scripts/receipt-auth.mjs`) with a real session cookie |
| Instance model-gateway token expired | `MODEL_GATEWAY_TOKEN` is the operator's Access token. It is shared by every user and lasts about 30 days. | `cloudflared access login <app>` and then `ship.sh` again |

---

## Not this pattern: host inference

An operator tool (for example, **skin**) uses **host inference**. Its Worker's own `AI` binding or gateway pays for every call. That is fine for an operator's own tool or an Access-gated internal app. It is **not** the public `*.coey.dev` funnel.

Before you copy code, decide which one you are building:

| | Host inference (operator tools, Access apps) | User inference (this funnel) |
|---|---|---|
| Who pays | The host account | Each signed-in person |
| Auth | Access, or none | Sign in with Cloudflare (OAuth) |
| Model call | `env.AI` binding / host gateway | `accounts/<user acct>` + user token |
| Public sharing | Risky (the host pays for everyone) | Safe (no per-user cost to the host) |

Apps that copied coil halfway and mixed these two usually broke in one of these ways: they fall back to `env.AI` for signed-in users, they keep a stale copy of the grant, or they leave the Durable Object name inconsistent. **Check them against the invariants above.**

---

## Proof (receipts) for an app in this funnel

- `scripts/receipt-auth.mjs <origin>` with a real session cookie file. It must check that anonymous requests are refused, a signed-in user can create a thread, the model switch works, a turn is sent, and the model answers. Run it against prod after every auth or routing change.
- Unit tests must cover: `pickGateway`, rejecting gateways with auth off, scheme-specific cookies, the fresh per-call route, and that a `cf:` owner never touches `env.AI`.
- `/oauth/me` returns `{ signedIn, account, gateway }` for quick manual checks.

---

## New app checklist

- [ ] `deploy/example.conf` copied to `deploy/<instance>.conf` (gitignored); domain on a `coey.dev` subdomain
- [ ] OAuth client created by hand (table above); secret piped into the Worker
- [ ] `SESSION_SECRET` set; `CLOUDFLARE_ACCOUNT_ID` passed to every secret command
- [ ] Public landing, `/docs`, `/llms.txt` and `/robots.txt` served before the gate
- [ ] Publisher TXT record verified; client made Public
- [ ] Account and gateway shown in the UI; gateway picker reachable
- [ ] No `env.AI` fallback for `cf:` owners (grep for it)
- [ ] `receipt-auth.mjs` passes against prod
- [ ] Social card: absolute `og:image`, `twitter:card`; landing not set to noindex

How to use this prompt

  1. Copy the prompt using the button above
  2. Paste it into your preferred AI coding assistant
  3. Adjust any placeholders or context as needed
  4. Let the agent implement the changes