Open source · Apache 2.0 · self-hosted

Ship AI features in your iOS app
without shipping your API keys.

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.

  • No provider key ever reaches the device
  • Apple App Attest + your identity provider
  • Per-user limits and monthly spend caps
The App AI Gateway console listing four applications with month-to-date spend, requests, tokens, users, and per-app budget usage bars.The App AI Gateway console listing four applications with month-to-date spend, requests, tokens, users, and per-app budget usage bars.

The problem

A key in your app bundle is a public key.

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.

  • Extraction. Strings, network interception, and a jailbroken device are enough. Rotating the key means shipping a new build and waiting on App Review.
  • No identity. The provider sees one account, not your users. Abuse looks identical to growth until the invoice arrives.
  • No ceiling. Provider-side spend caps are account-wide. One scripted client can consume the budget for everyone.

Built for iOS

Everything an app needs between the device and the model.

The gateway was written for mobile clients first: untrusted, publicly distributed, and impossible to patch quickly. Every feature follows from that.

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.

Apple App Attest

Requests are accepted only from genuine, unmodified installs of your app, verified against your team and bundle identifier with replay-resistant assertion counters.

Your identity provider

Firebase, Auth0, or any JWKS issuer. Require specific claims — issuer, audience, or a subscription entitlement — before a token is issued.

Per-user rate limits

Requests per minute and per day, applied to each authenticated user independently and serialized through a Durable Object so they hold under concurrency.

Per-user spend caps

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.

Block a user instantly

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.

Streaming preserved

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.

Change models without a release

Allowed models, allowed paths, output-token ceilings, and model rewrites are configuration. Move from one model to another without App Review.

A Swift client included

AIGatewayClient handles attestation, key registration, the token exchange, Keychain storage, and silent refresh — plus a Simulator path for local development.

Two lines of setup, then ordinary requests

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
LumenJournal/AI/Gateway.swiftSwift
// 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

Two proofs before a single token is issued.

Identity answers who is asking. Attestation answers what is asking. The gateway requires both, and you decide how strict each one has to be.

The authentication policy tab showing the issuer JWKS URL, the user id claim, a list of required token claims, and the App Attest card with team identifier, bundle identifier, and accepted environments.The authentication policy tab showing the issuer JWKS URL, the user id claim, a list of required token claims, and the App Attest card with team identifier, bundle identifier, and accepted environments.
Authentication policy for one application — issuer, required claims, and App Attest binding.
  • Provider keys never leave the server. They live in Cloudflare AI Gateway BYOK / Secrets Store, not in the repository, not in Worker variables, not in the database.
  • Short-lived tokens. The device holds a bearer token scoped to your gateway with a lifetime you cap. There is nothing durable worth stealing.
  • Claim-level gating. Require that a token's issuer, audience, or entitlement claims match before it is honoured — so a free user cannot call a paid model.
  • Path and model allow-lists. A compromised client can only reach the exact provider paths and models you enabled, with your max_output_tokens ceiling applied.
  • Header hygiene. Only documented Cloudflare AI Gateway control headers are forwarded; anything else a client tries to inject is stripped.
  • Hashed credentials. Application API keys and development credentials are stored as hashes with a display prefix. The plaintext is shown once, at creation.

Visibility

Know what every request cost, and who made it.

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.

A stacked bar chart of thirty days of daily spend, split by provider, with weekday and weekend variation clearly visible.
Daily cost by provider. Switch the same chart to requests or tokens, over 7, 30, or 90 days.
A breakdown table grouped by model, listing requests, input tokens, cached tokens, output tokens, and cost for each model.
Break usage down by model, provider, user, status, route, or app version.
A table of recent proxied requests showing timestamp, user, model, route, tokens, cost, latency, and a status badge including blocked and error states.
Every proxied request, newest first — including the ones that were blocked.

Priced, not estimated

Input, cached input, cache-write, and output tokens are recorded separately and costed from a checked-in price table per model.

Attributed to a user

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.

Blocks are recorded too

Rate-limited, budget-exhausted, and blocked-user attempts appear alongside successes, which is how you tell abuse from demand.

Limits & cost control

A ceiling per user, and a ceiling for the app.

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.

The limits tab showing per-user requests per minute, requests per day, and monthly spending budget alongside the same fields scoped to the whole application.The limits tab showing per-user requests per minute, requests per day, and monthly spending budget alongside the same fields scoped to the whole application.
Per-user and per-application limits for one tenant.

Enforced before the provider is called

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.

Correct under concurrency

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.

Visible before it hurts

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.

Every user, with their own spend

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.

The users tab listing authenticated users with their attestation environment and counter, last-seen time, request count, token count, monthly cost, and blocked users.The users tab listing authenticated users with their attestation environment and counter, last-seen time, request count, token count, monthly cost, and blocked users.
Per-user attestation state and month-to-date usage, with blocked accounts.
See the configuration reference

Providers

Five providers, native contracts, no translation layer.

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.

  • OpenAIv1/responses · v1/chat/completions · v1/audio/transcriptions
  • Anthropicv1/messages
  • xAIv1/responses · v1/stt
  • Google Geminiv1beta/models/*:generateContent · OpenAI-compatible
  • Perplexitychat/completions
The proxy policy tab showing which providers are enabled for an application, with their allowed request paths, allowed models, and maximum output tokens.The proxy policy tab showing which providers are enabled for an application, with their allowed request paths, allowed models, and maximum output tokens.
Provider access for one application — allowed paths, allowed models, output ceilings.

Per-app allow-lists

Each application decides which providers it may reach, which paths on those providers, and which exact model identifiers. Everything else is refused.

Model rewrites

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.

Runs on Cloudflare AI Gateway

Upstream calls go through your Cloudflare AI Gateway, so its caching, logging, and provider-key management apply underneath this one.

How it works

One Worker between your app and every provider.

  1. 1

    The app proves itself

    The client requests a challenge, registers its App Attest key once, then submits a fresh assertion together with your issuer's identity token.

  2. 2

    The gateway decides

    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.

  3. 3

    The request is proxied

    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.

  4. 4

    Usage is recorded

    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.

An application overview screen showing month-to-date requests, tokens, cost, and budget usage, alongside the resolved configuration: issuer, user id claim, rate limits, App Attest environments, enabled providers, and registered users.An application overview screen showing month-to-date requests, tokens, cost, and budget usage, alongside the resolved configuration: issuer, user id claim, rate limits, App Attest environments, enabled providers, and registered users.
One tenant, resolved: identity, limits, providers, and month-to-date usage.

One row per application

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.

Edit it safely

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

Not just mobile.

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.

Server backendcurl
# 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":[...]}'
See the server API keys screen
The server API keys page listing three application keys with their display prefixes, creation dates, last-used times, and one revoked key.The server API keys page listing three application keys with their display prefixes, creation dates, last-used times, and one revoked key.
Application API keys for a server tenant, including a revoked key.

Deploy

Your Cloudflare account. Your keys. Your bill.

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.

LicenseApache 2.0, on GitHub
RuntimeCloudflare Workers + D1 + Durable Objects
ClientsSwift package, or plain HTTP
Middleman feesNone

FAQ

Questions worth asking first.

How is this different from calling the provider directly from the app?

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.

Where do the provider keys actually live?

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.

Do I have to use a specific model SDK?

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.

What does it cost to run?

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.

How does it know who a user is?

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.

Can I use it from a server rather than an iOS app?

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.

What happens when a user hits a limit?

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.

Is it really free?

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.

Stop shipping your API key.

Read the source, run it locally in a few minutes, and decide for yourself.