# Configure sign-in and AI Gateway billing

> Turn on the optional public-service posture: `AUTH_GATEKEEPERS` allowlisting, `DISABLE_PASSWORD_AUTH`, email-keyed identity via `idFromName(email)`, incremental auth-versus-full scopes with transient login grants, and `ENABLE_CLOUDFLARE_LIMITS` free-tier plus credit top-up with the `$2` balance threshold and 5-minute balance cache.

- Repository: cloudflare/cloudflare-os
- GitHub: https://github.com/cloudflare/cloudflare-os
- Human docs: https://grok-wiki.com/public/docs/cloudflare-cloudflare-os-838773bb92dd
- Complete Markdown: https://grok-wiki.com/public/docs/cloudflare-cloudflare-os-838773bb92dd/llms-full.txt

## Source Files

- `docs/oauth-signin.md`
- `docs/ai-gateway-billing.md`
- `docs/public-server.md`
- `packages/workshop-backend/src/auth/config.ts`
- `packages/workshop-backend/src/auth/login-flow.ts`
- `packages/workshop-shared/src/limits.ts`
- `packages/workshop-backend/src/ai-gateway.ts`

---

---
title: "Configure sign-in and AI Gateway billing"
description: "Turn on the optional public-service posture: `AUTH_GATEKEEPERS` allowlisting, `DISABLE_PASSWORD_AUTH`, email-keyed identity via `idFromName(email)`, incremental auth-versus-full scopes with transient login grants, and `ENABLE_CLOUDFLARE_LIMITS` free-tier plus credit top-up with the `$2` balance threshold and 5-minute balance cache."
---

Two independent switches turn the Workshop from a self-hosted single-tenant deployment into a public, multi-user service: `AUTH_GATEKEEPERS` opts specific auth-capable gatekeepers into the sign-in UI, and `ENABLE_CLOUDFLARE_LIMITS` enables the free daily LLM allowance plus Cloudflare-credits top-up flow. Both are off by default — with no allowlist the Workshop keeps username/password (or Cloudflare Access), and with limits disabled AI usage is unlimited. There is no single master switch; each piece is configured separately.

## What each switch does

| Configure | Effect |
| --- | --- |
| `AUTH_GATEKEEPERS=cloudflare,google,github` | Allowlists which gatekeepers may sign users in. Each listed, auth-capable gatekeeper gets a "Continue with …" button alongside username/password. Order is button order. |
| Each gatekeeper's OAuth credentials (on the gatekeeper Worker) | Required for that gatekeeper to actually authenticate. |
| `ENABLE_CLOUDFLARE_LIMITS=true` | Enables the free daily LLM-call limit plus Cloudflare-credits top-up flow. |
| `DISABLE_PASSWORD_AUTH=true` | Hides username/password, leaving gatekeeper sign-in only. Ignored unless `AUTH_GATEKEEPERS` is non-empty. |

<Warning>
`DISABLE_PASSWORD_AUTH=true` is deliberately inert when the allowlist is empty. `isPasswordAuthEnabled()` in `packages/workshop-backend/src/auth/config.ts` returns `true` whenever `hasAuthGatekeepers(env)` is false, so a misconfigured deployment cannot lock every user out.
</Warning>

## Sign-in via authentication gatekeepers

Sign-in is provided by gatekeepers that advertise `providesAuth` and can return a provider-verified email. Each such gatekeeper uses a **single OAuth app** for both sign-in and later capability use — there is no separate "login" app per provider.

### Allowlist parsing

`getAuthGatekeeperAllowlist(env)` splits `AUTH_GATEKEEPERS` on commas, trims each entry, lowercases it, and drops empties. It returns `[]` when the variable is unset.

```ts
// packages/workshop-backend/src/auth/config.ts
export function getAuthGatekeeperAllowlist(env: Cloudflare.Env): string[] {
  const raw = (env as { AUTH_GATEKEEPERS?: string }).AUTH_GATEKEEPERS;
  if (!raw) return [];
  return raw.split(",").map(s => s.trim().toLowerCase()).filter(Boolean);
}
```

Allowlisting is necessary but not sufficient: a vendor must also actually advertise `providesAuth` to be offered as a sign-in option.

### Identity is keyed by verified email

The primary account key is always the user's verified email. The `UserDurableObject` is addressed by `idFromName(email)` — the same scheme Cloudflare Access uses — so signing in with any allowlisted gatekeeper that yields the same verified email resolves to the same account.

Each gatekeeper must only return an email the provider has verified:

| Vendor | Verified-email source |
| --- | --- |
| `google` | `email_verified` claim |
| `github` | primary + verified email |
| `cloudflare` | the Cloudflare account email |

If no verified email is available, the gatekeeper returns `null` and cannot be used to sign in. `LoginConnectCallbackImpl.complete()` then fails the attempt with `"This account has no verified email, so it can't be used to sign in."`

<Note>
Session tokens are `"<doName>:<secret>"`, and `PublicApi.authenticate()` routes via `idFromName` of the first part. Because the user DO is keyed by email, the token prefix must be the email — `complete()` delivers `` `${email}:${secret}` ``.
</Note>

### Incremental scopes: `auth` versus `full`

`GatekeeperVendor.connectAccount` takes `{ scopes: "auth" | "full" }`.

<ParamField body="scopes: &quot;auth&quot;" type="string">
Minimal scopes needed only to verify the user's email — e.g. GitHub `read:user user:email`, Google `openid email profile`, Cloudflare `offline_access user-details.read`. The resulting gatekeeper grant is **transient**: it self-destructs shortly after the email is read, so signing in never leaves a broad authorization lying around, and no connected account is persisted.
</ParamField>

<ParamField body="scopes: &quot;full&quot;" type="string" default="full">
The default for `connectAccount(vendorId)`. Requests the fuller capability scopes (repos, Gmail/Docs, AI Gateway billing) and persists a usable connected account.
</ParamField>

Capability access is therefore always a second, explicit step for a user who signed in with a non-Cloudflare provider.

### Sign-in flow

```mermaid
sequenceDiagram
    participant C as Browser client
    participant P as PublicApi
    participant DO as PendingLogin DO
    participant GK as Gatekeeper Worker
    participant CB as LoginConnectCallbackImpl
    participant U as UserDurableObject

    C->>P: startGatekeeperLogin(vendorId)
    P->>DO: create (random DO id)
    P->>GK: connect flow + LoginConnectCallbackImpl
    P-->>C: { url, attempt } (RpcStub, no login id)
    C->>GK: open url in pop-up
    C->>DO: attempt.wait() (blocks)
    GK->>CB: complete(account, expiresAt?)
    CB->>CB: account.getAuthenticatedEmail()
    CB->>U: get(idFromName(email))
    CB->>U: loginOrCreateViaGatekeeper(email, signupsEnabled)
    CB->>DO: deliver("<email>:<secret>")
    DO-->>C: resolves attempt.wait()
```

Steps as implemented in `packages/workshop-backend/src/auth/login-flow.ts`:

<Steps>
<Step title="Start the attempt">
`PublicApi.startGatekeeperLogin(vendorId)` creates a short-lived `PendingLogin` DO keyed by a random DO id, hands the gatekeeper a `LoginConnectCallbackImpl`, and returns the gatekeeper's OAuth `url` plus an `attempt` stub. The `attempt` is an `RpcStub` wrapping the DO, so the client awaits via a capability and no guessable login id is ever exposed.
</Step>
<Step title="Wait on the capability">
The client opens `url` in a pop-up (the gatekeeper's self-closing OAuth window) and calls `attempt.wait()`, which blocks on `PendingLogin.awaitResult()`.
</Step>
<Step title="Resolve identity">
When the gatekeeper finishes it calls `complete(user)`. The callback reads `user.getAuthenticatedEmail()`, resolves or creates the email-keyed `UserDurableObject`, and mints a session via `loginOrCreateViaGatekeeper(email, signupsEnabled)`. The email's local part seeds the initial display name.
</Step>
<Step title="Deliver the token">
`pending.deliver("<email>:<secret>")` resolves the awaiting RPC; the client stores the token and authenticates as usual.
</Step>
</Steps>

### Signup gating and failure outcomes

`complete()` reads `readAdminConfig(this.env).signupsEnabled` and passes it into `loginOrCreateViaGatekeeper`. A `null` secret means first-time account creation was blocked. Existing users signing in are unaffected.

Each terminal path emits a `gatekeeper.login.finished` log event with an `outcome` field:

| `outcome` | Cause | Message delivered to the client |
| --- | --- | --- |
| `ok` | Session minted | token `"<email>:<secret>"` |
| `no_email` | `getAuthenticatedEmail()` returned falsy | `This account has no verified email, so it can't be used to sign in.` |
| `signups_disabled` | `loginOrCreateViaGatekeeper` returned `null` | `New sign-ups are currently disabled on this deployment.` |
| `error` | Thrown exception (also logs `gatekeeper.login.failed`) | `Sign-in failed. Please try again.` |

### The Cloudflare special case

Cloudflare sign-in is the one vendor where login also persists a connected account, because the same grant funds billing. `startGatekeeperLogin` requests full (non-transient) scopes for `CLOUDFLARE_VENDOR_ID`, and `complete()` calls `userStub.linkConnectedAccountFromLogin(account, vendorId, expiresAt)` before handing back the session. All other providers use minimal, transient sign-in grants and persist nothing.

### Sign-in storage and bindings

`PendingLogin` is a Durable Object reached via `ctx.exports` (no explicit binding) and holds **no durable storage**. Awaiters live in an in-memory `#waiters` array, with a one-time-use `#result` stash for the rare case where `deliver()`/`fail()` arrives before `awaitResult()` registers. The in-flight `awaitResult()` request keeps the DO alive; if the attempt is abandoned the client disposes the `attempt` stub and the DO is simply evicted — no alarm or cleanup needed.

### Sign-in code layout

```text
auth/
├── config.ts          # AUTH_GATEKEEPERS allowlist; password-auth toggle
├── auth-vendors.ts    # GATEKEEPER_<NAME> binding lookup helpers
└── login-flow.ts      # PendingLogin DO + LoginConnectCallbackImpl
```

Client-side, `ServerConfigContext` exposes `authVendors` and `passwordAuthEnabled`; `components/auth/OAuthButtons` renders the sign-in options (pop-up plus `attempt.wait()`).

## AI Gateway billing

With `ENABLE_CLOUDFLARE_LIMITS=true`, each user gets a free allowance of LLM calls per UTC day (default 100), counted on the user's own `UserDurableObject` via `consumeDailyLlmCall` / `checkDailyLlmCount`. Before each user-initiated agent turn the overseer calls `checkUsageAndBalance`.

### Decision matrix

`canProceedWithRequest` in `packages/workshop-shared/src/limits.ts` is the pure decision function. It takes `{ withinLimits, hasUserToken, balance, minimumBalance? }` and returns `CanProceedResult`:

| Connected token | Balance | Within free tier | Result |
| --- | --- | --- | --- |
| yes | ≥ `$2` | either | `allowed: true`, `shouldUseByok: true` — billed to the user's own gateway even while free-tier allowance remains; the daily counter is left untouched |
| yes | < `$2` (incl. `$0`) | yes | `allowed: true`, `shouldUseByok: false` — platform-funded free tier |
| no | — | yes | `allowed: true`, `shouldUseByok: false` — platform-funded free tier |
| no | — | no | `allowed: false`, reason `LIMIT_ERROR_MESSAGES.NO_CLOUDFLARE_TOKEN` — prompt to connect |
| yes | < `$2` | no | `allowed: false`, reason `insufficientBalanceMessage(minimum)` — prompt to add credits |

```ts
// packages/workshop-shared/src/limits.ts
if (hasUserToken && hasMinimumBalance(balance, minimumBalance)) {
  return { allowed: true, shouldUseByok: true };
}
if (withinLimits) {
  return { allowed: true, shouldUseByok: false };
}
```

<Warning>
`shouldUseByok` is only meaningful when `allowed` is true — it selects whose credentials to use. When `allowed` is false the request never runs, so the value is a don't-care. Callers must check `allowed` first.
</Warning>

`hasMinimumBalance(balance, minimum)` returns `false` for both `null` and `undefined`, so an unknown balance never satisfies the threshold.

### Shared constants and messages

<ResponseField name="MINIMUM_CLOUDFLARE_BALANCE" type="number" default="2.0">
Minimum Cloudflare AI Gateway balance in USD required to proceed via BYOK. Overridable per deployment with `MINIMUM_CLOUDFLARE_BALANCE`.
</ResponseField>

<ResponseField name="DEFAULT_DAILY_LLM_CALL_LIMIT" type="number" default="100">
Free-tier LLM calls per user per calendar day. Overridable with `DAILY_LLM_CALL_LIMIT`.
</ResponseField>

<ResponseField name="LIMIT_ERROR_MESSAGES.USAGE_LIMIT_EXCEEDED" type="string">
`Free usage limit reached. Connect your Cloudflare account or use your own API keys to continue.`
</ResponseField>

<ResponseField name="LIMIT_ERROR_MESSAGES.NO_CLOUDFLARE_TOKEN" type="string">
`Free usage limit reached. Connect your Cloudflare account to continue.`
</ResponseField>

<ResponseField name="insufficientBalanceMessage(minimum)" type="() => string">
Returns `Cloudflare AI Gateway balance is below $<minimum>. Please add credits or use BYOK.`
</ResponseField>

<ResponseField name="LimitWindowKind" type="&quot;daily&quot; | &quot;rolling&quot;">
The window over which the free-tier limit is measured.
</ResponseField>

### Balance reads and the 5-minute cache

The balance shown to users is read live from the user's Cloudflare AI Gateway billing endpoint `/ai-gateway-billing/credit_balance` and **cached for 5 minutes**. Topping up means adding credits in the Cloudflare dashboard (`https://dash.cloudflare.com/?to=/:account/ai/ai-gateway`) — the platform never holds money.

### Connecting Cloudflare for billing

Billing is tied to the Cloudflare gatekeeper: OAuth tokens live in that gatekeeper's connection, and the billing flow obtains a usable token from it via `getUsableAccessToken()`. A user connects Cloudflare either by signing in with it, or — if they signed in another way — via the "Connect Cloudflare" button, which runs the normal `AuthenticatedApi.connectAccount("cloudflare")` flow.

The account to bill is auto-selected when the grant sees exactly one account; with several, the user is prompted to choose. Billing is account-level (Unified Billing): inference routes through the account's auto-created "default" AI Gateway.

### Billing storage and bindings

```text
UserDurableObject
├── daily LLM-call counter        (free tier; no separate binding)
├── selected Cloudflare account id
└── cached credit balance         (5-minute TTL)

Cloudflare gatekeeper account
└── OAuth tokens                  (never stored on the user DO)
```

### Billing code layout

Server code lives under `packages/workshop-backend/src/ai-gateway-billing/`:

```text
ai-gateway-billing/
├── config.ts                     # ENABLE_CLOUDFLARE_LIMITS / minimum-balance readers
├── limits/
│   ├── config.ts                 # daily-limit + calendar-day helpers + DailyQuotaResult
│   └── usage-checker.ts          # checkUsageAndBalance / getUsageInfo (counter lives on UserDurableObject)
└── cloudflare/
    ├── account-service.ts        # CF REST: accounts / balance
    └── connection-service.ts     # token (from CF gatekeeper), account selection, balance cache, BYOK routing
```

Client-side, `ServerConfigContext` exposes `cloudflareLimitsEnabled`; `components/billing/` (`UsageSettings`, `OutOfCreditsModal`, `AccountSelectionModal`) renders the usage, top-up, and account-selection UI.

## Configuration reference

<Tabs>
<Tab title="Sign-in only">

```bash
PUBLIC_BASE_URL=https://your-host
AUTH_GATEKEEPERS=cloudflare,google,github   # which gatekeepers may sign users in (order = button order)

# Optional: gatekeeper sign-in only (hide username/password).
DISABLE_PASSWORD_AUTH=true
```

</Tab>
<Tab title="Sign-in + billing">

```bash
ENABLE_CLOUDFLARE_LIMITS=true
PUBLIC_BASE_URL=https://your-host
AUTH_GATEKEEPERS=cloudflare       # allow Cloudflare sign-in/connect (plus any others)

# The Cloudflare gatekeeper's OAuth app (client id/secret live on the gatekeeper Worker):
CLOUDFLARE_OAUTH_CLIENT_ID=...
CLOUDFLARE_OAUTH_CLIENT_SECRET=...

# Platform AI Gateway used for the free tier:
CF_AI_GATEWAY=your-gateway
CF_AI_GATEWAY_PROVIDERS=anthropic,openai,google

# Required whenever CF_AI_GATEWAY is set (all inference goes over HTTPS with tokens):
CF_AI_GATEWAY_ACCOUNT_ID=...
CF_AI_GATEWAY_API_TOKEN=...

# To send Workers AI straight to its REST endpoint (no gateway, no cost logs):
CF_AI_GATEWAY_WAI_DIRECT=true
```

</Tab>
<Tab title="Local .dev.vars">

Set the variables in a root `.dev.vars` file (gitignored, `KEY=VALUE` per line); `pnpm run dev-server` loads it automatically.

```bash
ENABLE_CLOUDFLARE_LIMITS=true
PUBLIC_BASE_URL=http://localhost:8787
AUTH_GATEKEEPERS=cloudflare,google,github

# Each gatekeeper's OAuth app (client id/secret). In dev these seed the gatekeeper Workers:
GITHUB_CLIENT_ID=...
GITHUB_CLIENT_SECRET=...
GOOGLE_CLIENT_ID=...
GOOGLE_CLIENT_SECRET=...
CLOUDFLARE_OAUTH_CLIENT_ID=...
CLOUDFLARE_OAUTH_CLIENT_SECRET=...

CF_AI_GATEWAY=your-gateway
CF_AI_GATEWAY_PROVIDERS=anthropic,openai,google
CF_AI_GATEWAY_ACCOUNT_ID=...
CF_AI_GATEWAY_API_TOKEN=...
CF_AI_GATEWAY_WAI_DIRECT=true
```

`run-dev-server.js` seeds each gatekeeper's `CLIENT_ID`/`CLIENT_SECRET` from the `GOOGLE_*` / `GITHUB_*` / `CLOUDFLARE_OAUTH_*` shell vars.

</Tab>
</Tabs>

### Optional overrides

```bash
DAILY_LLM_CALL_LIMIT=100        # free-tier LLM calls per user per UTC day
MINIMUM_CLOUDFLARE_BALANCE=2    # min connected-account balance (USD) to proceed via BYOK
```

### Redirect URIs

Register each gatekeeper's OAuth app with its own redirect URI, substituting `PUBLIC_BASE_URL` for the host:

| Vendor | Redirect URI |
| --- | --- |
| `github` | `${PUBLIC_BASE_URL}/gatekeeper/github/oauth` |
| `google` | `${PUBLIC_BASE_URL}/gatekeeper/google/oauth` |
| `cloudflare` | `${PUBLIC_BASE_URL}/gatekeeper/cloudflare/oauth` |

OAuth app credentials live on the **gatekeeper Workers**, not on the backend.

### Cloudflare gatekeeper OAuth endpoints

The Cloudflare dashboard endpoints and scopes are hardcoded in `packages/gatekeeper-cloudflare/src/oauth.ts`:

| Field | Value |
| --- | --- |
| auth | `https://dash.cloudflare.com/oauth2/auth` |
| token | `https://dash.cloudflare.com/oauth2/token` |
| scopes | `offline_access aig.read aig.run aig.write user-details.read account-settings.read` |

## AI Gateway routing details

Gateway mode always requires `CF_AI_GATEWAY_ACCOUNT_ID` and an API token with **AI Gateway Run and Read** permissions. Read access lets Gadgets retrieve each log's cost for user-visible accounting. The Gateway may live in the Worker's own account or a different one.

| Variable | Effect on Workers AI |
| --- | --- |
| (default) | Uses `CF_AI_GATEWAY` as its Gateway ID |
| `CF_AI_GATEWAY_WAI=<gateway>` | Routes Workers AI through a different Gateway in the same account |
| `CF_AI_GATEWAY_WAI_DIRECT=true` | Bypasses gateways and calls the Workers AI REST endpoint directly with the same account/token pair; produces no cost logs |

<Tip>
When using `CF_AI_GATEWAY*` in local development, start the server with `pnpm run dev-server -- --use-workers-ai-binding` so the `webFetch` tool's document-to-Markdown conversion still has a `WORKERS_AI` binding. Inference itself no longer uses the binding — it goes over HTTPS with the tokens above.
</Tip>

## Verification checklist

<Check>
With `AUTH_GATEKEEPERS` unset, no "Continue with …" buttons appear and username/password remains available regardless of `DISABLE_PASSWORD_AUTH`.
</Check>

<Check>
With `AUTH_GATEKEEPERS` set, `ServerConfigContext.authVendors` lists exactly the allowlisted vendors that also advertise `providesAuth`, in allowlist order.
</Check>

<Check>
Signing in with two different allowlisted gatekeepers that report the same verified email lands on one account — the same `idFromName(email)` DO.
</Check>

<Check>
With `ENABLE_CLOUDFLARE_LIMITS` unset, `ServerConfigContext.cloudflareLimitsEnabled` is false and no daily counter or balance check applies.
</Check>

<Check>
A connected Cloudflare account funded above `$2` routes through the user's own gateway and leaves the daily free-tier counter unchanged.
</Check>

## Troubleshooting

<AccordionGroup>
<Accordion title="DISABLE_PASSWORD_AUTH=true has no effect">
`isPasswordAuthEnabled()` short-circuits to `true` when the `AUTH_GATEKEEPERS` allowlist is empty. Set a non-empty allowlist first.
</Accordion>

<Accordion title="Sign-in fails with &quot;This account has no verified email&quot;">
The gatekeeper's `getAuthenticatedEmail()` returned no verified email. Confirm the provider marks the address verified (Google `email_verified`, a GitHub primary+verified email, the Cloudflare account email). Unverified addresses are rejected by design.
</Accordion>

<Accordion title="Sign-in fails with &quot;New sign-ups are currently disabled&quot;">
`readAdminConfig(env).signupsEnabled` is false, so `loginOrCreateViaGatekeeper` returned `null` and blocked first-time account creation. Existing users are unaffected.
</Accordion>

<Accordion title="A gatekeeper appears in the allowlist but has no button">
Allowlisting is not sufficient — the vendor must also advertise `providesAuth`, and its OAuth credentials must be present on the gatekeeper Worker for it to actually authenticate.
</Accordion>

<Accordion title="User is blocked despite having connected Cloudflare">
The connected balance is below the minimum and the free tier is exhausted, so `canProceedWithRequest` returns `allowed: false` with `insufficientBalanceMessage`. Add credits in the Cloudflare dashboard; the balance is cached for 5 minutes, so the change may take that long to surface.
</Accordion>

<Accordion title="Balance shows as blocked right after topping up">
`hasMinimumBalance` treats `null`/`undefined` as failing, and the live `/ai-gateway-billing/credit_balance` read is cached for 5 minutes. Wait out the cache window.
</Accordion>

<Accordion title="Workers AI calls fail in local dev with CF_AI_GATEWAY set">
Start with `pnpm run dev-server -- --use-workers-ai-binding` so the `webFetch` document-to-Markdown path still has a `WORKERS_AI` binding.
</Accordion>
</AccordionGroup>

## Related pages

<CardGroup cols={2}>
<Card title="Configure gatekeeper credentials" href="/configure-gatekeeper-credentials">
Register the OAuth apps behind each auth gatekeeper and wire `CLIENT_ID`/`CLIENT_SECRET` onto the gatekeeper Workers.
</Card>
<Card title="Environment variables" href="/environment-variables">
Full backend variable list including the `CF_AI_GATEWAY*` family, `DAILY_LLM_CALL_LIMIT`, and `MINIMUM_CLOUDFLARE_BALANCE`.
</Card>
<Card title="Gatekeeper protocol" href="/gatekeeper-protocol">
`providesAuth`, `AccountDescription`, and the connector interfaces that make a vendor sign-in capable.
</Card>
<Card title="RPC API reference" href="/rpc-api-reference">
`PublicApi`, `LoginAttempt`, `AuthenticatedApi`, and `ServerConfig` shapes used by the sign-in and billing UI.
</Card>
<Card title="Admin configuration reference" href="/admin-configuration">
`signupsEnabled` and why auth config is deliberately excluded from `AdminConfig`.
</Card>
<Card title="Local development" href="/local-development">
`.dev.vars` loading, gatekeeper service-binding discovery, and the `--use-workers-ai-binding` flag.
</Card>
</CardGroup>
