AI Gateway + OpenCode Architecture
How my Cloudflare AI Gateway is wired up so a coding agent (OpenCode) can authenticate with Cloudflare Access and pull its config remotely, with no API keys on the laptop.
The moving parts
Four pieces work together. Everything is Cloudflare native, and only the AI Gateway hostname is protected by Access.
| Component | What it does |
|---|---|
aig.demo.clarkmerrick.com |
AI Gateway custom domain (CNAME → gateway.ai.cloudflare.com), routing to the opencode gateway. Protected by Cloudflare Access. Unified Billing supplies provider credentials, so no keys leave Cloudflare. |
opencode-config.demo.clarkmerrick.com |
A Cloudflare Worker that serves two small JSON files that carry no credentials: /opencode (discovery) and /opencode.json (provider config). Not behind Access. |
| Single Redirect rule | A dynamic redirect on the zone that forwards aig.demo.clarkmerrick.com/.well-known/opencode to the Worker's discovery file. Runs at the edge before Access. |
| Cloudflare Access app | A self hosted Access application guarding aig.demo.clarkmerrick.com. Users authenticate via SSO; cloudflared mints a short lived JWT the agent sends on every request. |
Topology at a glance
The laptop only ever talks to Cloudflare. Discovery/config traffic hits the Worker; model traffic hits the Access protected gateway.
(A) discovery + auth (B) model inference
┌─────────────────────────┐
│ Developer laptop │
│ • opencode CLI │
│ • cloudflared │
└───────────┬─────────────┘
│
(A) GET /.well-known/opencode (B) POST /compat/chat/completions
Host: aig.demo.clarkmerrick.com Host: aig.demo.clarkmerrick.com
│ cf-access-token: <JWT>
▼ │
┌───────────────────────────────────┐ │
│ Cloudflare edge (demo zone) │ │
│ 1. Single Redirect ──302──┐ │ │
│ 2. Cloudflare Access │ │◄──────────────┘
└───────────────┬───────────────┘ │ validate JWT (aud + policy)
│ 302 to Worker │ │ allow
▼ │ ▼
┌───────────────────────────────┐ │ ┌──────────────────────────────┐
│ Worker: opencode-config... │ │ │ AI Gateway "opencode" │
│ /opencode (discovery) │ └──►│ logging • caching • billing │
│ /opencode.json (provider cfg) │ │ │ │
└───────────────────────────────┘ │ ▼ │
│ Workers AI @cf/* models │
└──────────────────────────────┘
Request flow, Phase A: discovery & login
This happens once when a user runs opencode auth login https://aig.demo.clarkmerrick.com (and again when the Access session expires).
1
OpenCode asks the gateway host for discovery. It requests the
/.well-known/opencode path on the AI Gateway domain.
GET /.well-known/opencode HTTP/2
Host: aig.demo.clarkmerrick.com
2
The Single Redirect answers first. Dynamic redirects run at the edge before Access, so this path is never challenged. It just 302s to the Worker.
HTTP/2 302 location: https://opencode-config.demo.clarkmerrick.com/opencode
3
The Worker returns the discovery file. It names the auth command to run and where to fetch the real provider config. No secrets here.
GET /opencode HTTP/2 Host: opencode-config.demo.clarkmerrick.com HTTP/2 200 content-type: application/json { "auth": { "command": ["cloudflared","access","login","--no-verbose", "-app=https://aig.demo.clarkmerrick.com/"], "env": "TOKEN" }, "remote_config": { "url": "https://opencode-config.demo.clarkmerrick.com/opencode.json" } }
4
cloudflared runs the Access login. A browser SSO flow completes against the Access app, and Cloudflare issues a short lived JWT scoped to that app. OpenCode captures it as
{env:TOKEN} and stores it locally.
5
OpenCode fetches the remote provider config. This tells it the base URL and headers to use. Note the credential is a header placeholder, not a key baked into the file.
GET /opencode.json HTTP/2 Host: opencode-config.demo.clarkmerrick.com HTTP/2 200 { "enabled_providers": ["cf-workers-ai"], "provider": { "cf-workers-ai": { "npm": "@ai-sdk/openai-compatible", "options": { "baseURL": "https://aig.demo.clarkmerrick.com/compat", "apiKey": "", "headers": { "cf-access-token": "{env:TOKEN}", "X-Requested-With": "XMLHttpRequest" } }, "models": { "workers-ai/@cf/...": { } } } } }
Request flow, Phase B: model inference
Every prompt the agent sends now flows through the Access protected gateway.
1
OpenCode calls the gateway's OpenAI compatible endpoint, attaching the Access JWT it minted in Phase A.
POST /compat/chat/completions HTTP/2 Host: aig.demo.clarkmerrick.com cf-access-token: <short lived Access JWT> X-Requested-With: XMLHttpRequest content-type: application/json { "model": "workers-ai/@cf/zai-org/glm-5.3", "messages": [ { "role": "user", "content": "refactor this function" } ] }
2
Cloudflare Access validates the JWT at the edge: signature, audience (
aud) tag, and the app's policies. No token (or an invalid one) is rejected before the gateway is ever touched:HTTP/2 401 www-authenticate: Bearer realm="OAuth", error="invalid_token", error_description="Missing or invalid access token"
3
A valid token is proxied to the
opencode AI Gateway. The gateway applies logging, caching, rate limiting and Unified Billing, then routes to the model provider.
4
Workers AI runs the model (e.g.
@cf/*) and the gateway returns a standard OpenAI shaped response back to the laptop.HTTP/2 200 content-type: application/json { "choices": [ { "message": { "role": "assistant", "content": "..." } } ], "usage": { "prompt_tokens": 128, "completion_tokens": 512 } }
Access & permissions flow
- Self hosted Access application is bound to the hostname
aig.demo.clarkmerrick.com. It has a unique audience (aud) tag that every issued JWT is stamped with. - Identity + policies decide who gets in. The login flow authenticates the user against the configured identity provider (SSO), then Access evaluates the app's policies (e.g. allowed email domains or specific users) before issuing a token.
- The token is short lived.
cloudflaredcaches it locally; when the session expires, runningopencode auth loginagain transparently mints a fresh one. - The gateway trusts Access, not the client. Because Access terminates in front of the gateway, the model endpoint only ever sees requests that are already authenticated.
- Discovery stays public on purpose. The Single Redirect fires in the dynamic redirect phase, which is ahead of Access in the request pipeline, so
/.well-known/opencoderesolves without a login prompt, while everything else on the host stays locked down. The files it exposes contain configuration only, never credentials.
Want the walkthrough?
Happy to demo this live. Best point of contact is email at [email protected]