Nothing secret on the device
The app holds no provider key, not even an encrypted one. It exchanges an identity token for a short-lived gateway token, and that is all it ever carries.
A self-hosted LLM proxy that runs on your own Cloudflare account. Provider keys stay on the server, every request is tied to a verified user, and the console shows exactly where the money went.


The problem
Every shipped binary is a copy of your secret handed to a stranger. And even if the key held, you would still have no idea which user burned $60 last Tuesday, or how to stop them.
Built for iOS
The gateway was written for mobile clients first: untrusted, publicly distributed, and impossible to patch quickly. Every feature follows from that.
The app holds no provider key, not even an encrypted one. It exchanges an identity token for a short-lived gateway token, and that is all it ever carries.
Requests are accepted only from genuine, unmodified installs of your app, verified against your team and bundle identifier with replay-resistant assertion counters.
Firebase, Auth0, or any JWKS issuer. Require specific claims — issuer, audience, or a subscription entitlement — before a token is issued.
Requests per minute and per day, applied to each authenticated user independently and serialized through a Durable Object so they hold under concurrency.
A monthly dollar budget for every user, and another for the app as a whole. Blocked requests never reach the provider, so they never cost anything.
One switch in the console cuts a user off immediately — the Durable Object is updated, so it applies even to a gateway token that is already live.
Responses stream straight through to the device. The gateway tees the stream for accounting instead of buffering it, so time-to-first-token stays intact.
Allowed models, allowed paths, output-token ceilings, and model rewrites are configuration. Move from one model to another without App Review.
AIGatewayClient handles attestation, key registration, the token exchange, Keychain storage, and silent refresh — plus a Simulator path for local development.
The Swift package ships in the repository. It returns a configured URLRequest, so your existing networking code, your models, and your existing streaming parser all keep working.
In the Simulator, where App Attest is unavailable, a separately issued development credential can stand in — but never in place of the user's identity token, and only when you explicitly enable it for that app.
Read the authentication guide// One client. It handles App Attest, the token exchange,
// Keychain storage, and silent refresh for you.
let gateway = AIGatewayClient(
appID: "lumen-journal",
baseURL: URL(string: "https://gateway.example.com")!,
issuerTokenProvider: { forceRefresh in
// Firebase, Auth0, or your own JWKS issuer
try await session.idToken(forceRefresh: forceRefresh)
}
)
// A normal provider request — no SDK lock-in, no rewritten body.
var request = try await gateway.authorizedRequest(
provider: "openai",
providerPath: "v1/responses"
)
request.httpBody = try JSONEncoder().encode(payload)
let (bytes, response) = try await URLSession.shared.bytes(for: request)Security model
Identity answers who is asking. Attestation answers what is asking. The gateway requires both, and you decide how strict each one has to be.


max_output_tokens ceiling applied.Visibility
Usage is recorded per request and priced per model, so cost is attributed down to an individual user on a specific app version — not averaged across an opaque provider invoice.



Input, cached input, cache-write, and output tokens are recorded separately and costed from a checked-in price table per model.
Every event carries the authenticated user id, the auth method, the app version, and the latency — so a regression has a shape you can see.
Rate-limited, budget-exhausted, and blocked-user attempts appear alongside successes, which is how you tell abuse from demand.
Limits & cost control
Set requests per minute, requests per day, and a monthly dollar budget — independently for each user and for the application as a whole. Leave a field empty for unlimited.


A request over its limit is rejected at the edge and returns rate_limited or budget_exhausted. It never reaches the provider, so a runaway client costs you nothing beyond a Worker invocation.
Counters live in a Durable Object, one per user, so ten simultaneous requests cannot each read the same stale count and slip through. Limits mean what they say.
The apps list shows each application's month-to-date spend against its budget, so an app trending toward its ceiling is obvious at a glance rather than at the end of the billing cycle.
A user row appears the first time a client completes a token exchange. From there you can see their attestation state and counter, when they were last seen, and what they have cost you this month. Blocking a user updates their Durable Object, so it takes effect immediately — including for a gateway token that has already been issued and has not yet expired.


Providers
The gateway deliberately does not invent a unified request format. Bodies and responses keep the provider's own shape, so the provider's own documentation stays authoritative and new fields work the day they ship.


Each application decides which providers it may reach, which paths on those providers, and which exact model identifiers. Everything else is refused.
Map a model name the app asks for onto a different model server-side. Roll a new model out to production, or roll it back, without touching the shipped binary.
Upstream calls go through your Cloudflare AI Gateway, so its caching, logging, and provider-key management apply underneath this one.
How it works
The client requests a challenge, registers its App Attest key once, then submits a fresh assertion together with your issuer's identity token.
It verifies the issuer signature and required claims, verifies the attestation against your team and bundle id, checks the user is not blocked, and returns a short-lived token.
Limits are evaluated in the user's Durable Object, the path and model are checked against the app's policy, and the provider key is attached server-side.
The response streams back untouched while the gateway tees it to count tokens, price the call, and write a usage event you can see in the console.
The whole stack: a Cloudflare Worker, a D1 database for configuration, users, and usage events, and a Durable Object per user for limits. Plus a React admin console served by the same Worker.


A tenant is a single configuration document — authentication, routing, and limits. There are no per-customer branches in the Worker, so adding an app is a database row, not a deployment.
The console gives you guided editing with the raw JSON always one tab away, and automation can validate a candidate configuration through the admin API before applying it. Browse the API reference →
Also for server backends
A trusted server can be a tenant too. It authenticates with a long-lived application API key instead of App Attest, and passes an optional X-End-User-ID header so per-user limits, budgets, and usage attribution work exactly as they do for an app.
Keys are hashed at rest, shown once at creation, and can be rotated or revoked from the console without redeploying.
# Trusted server backend — one long-lived key, one header.
curl "$GATEWAY/v1/apps/atlas-search/proxy/openai/v1/chat/completions" \
-H "Authorization: Bearer $AGW_KEY" \
-H "X-End-User-ID: customer-1042" \
-H "Content-Type: application/json" \
-d '{"model":"gpt-5.6-terra","messages":[...]}'

Deploy
You run it, so no vendor sits between you and your model provider and no third party sees your prompts. Deploy the Worker, the D1 database, the Durable Object, and the console in a single step.
Requires a Cloudflare account and a Cloudflare AI Gateway. You supply the gateway id, its authenticated token, and an admin token you generate — the rest is provisioned for you.
FAQ
A key compiled into an iOS binary is extractable. Anyone who pulls your IPA apart can bill your provider account until you notice. The gateway keeps the key server-side and only issues short-lived tokens to installations that pass Apple App Attest, so a stolen token is worth minutes, not months.
In Cloudflare AI Gateway's BYOK / Secrets Store, under the alias `default`. They are never placed in this repository, in Worker variables, or in the D1 database. The Worker attaches an encrypted authenticated gateway token to each upstream call.
No. The proxy is provider-native: request bodies and successful responses keep the upstream provider's exact contract, and streaming responses are streamed straight through. If you can call OpenAI today, you change the base URL and the auth header, and nothing else.
You pay Cloudflare for a Worker, a D1 database, and a Durable Object, plus whatever your model providers charge. Nobody takes a cut per request, because you own the deployment.
Your existing identity provider does. The app sends an issuer token (Firebase, Auth0, your own JWKS endpoint) plus an App Attest assertion. The gateway verifies the token's signature, issuer, audience, and any custom claims you require — for example a subscription entitlement — before it will proxy anything.
Yes. A server tenant authenticates with a long-lived application API key and optionally passes an `X-End-User-ID` header so per-user limits and usage attribution still apply. iOS is the case the project is designed around, but server backends are fully supported.
The request is rejected before it reaches the provider, so it costs you nothing, and the attempt is still recorded as a `blocked_rate` or `blocked_budget` event so you can see it in the console. Limits are serialized through a Durable Object, so they hold under concurrency.
Yes — the source is on GitHub under the Apache 2.0 license, and self-hosting costs you nothing beyond your own Cloudflare and model-provider usage. Fork it, audit it, run it, change it.
Read the source, run it locally in a few minutes, and decide for yourself.