A Gated Claude BFF at the Edge
Table of Contents
This is the working code behind Your API Key Doesn’t Belong in the App. A Backend for Frontend holds the Anthropic key, decides who may spend it, and proxies the Messages API. It runs as a single Cloudflare Worker, so the credential injection happens a few milliseconds from the caller wherever they are.
The whole gate is small, so the value is in how it splits. Each type lives in its own file: the configuration surface, the transaction shape, the pure decision, the two verifiers, and the proxy. The entry wires them together.
The configuration surface #
env.ts is the contract with wrangler.toml. Two gates can be turned on independently. StoreKit answers is this caller entitled? and needs a bundle allowlist plus the pinned Apple root. App Attest answers is this even my app talking? and needs your team and bundle identifiers. A path allowlist bounds which upstream endpoints an authorized caller can reach.
| |
The matching wrangler.toml carries the public variables and documents the one secret that never lands in source control.
| |
The transaction the gate reads #
A StoreKit 2 signed transaction carries far more than the gate needs. transaction.ts names only the fields the decision turns on, and it carries the field that does the heavy lifting: environment.
| |
A StoreKit 2 JWS is tagged Sandbox or Production, and Apple signs sandbox transactions with the same certificate chain as production. A sandbox Apple ID is free, so without an environment check anyone can mint a valid entitlement and clear the gate without paying. The decision treats anything other than Production as a failure.
The entitlement decision #
entitlement.ts is a pure function: a decoded transaction and a config in, a verdict out. No crypto, no network, which keeps it testable in isolation. It checks the bundle, then the optional product allowlist, then environment === "Production", then revocation, then expiry. Trial and paid collapse into one check, because a free trial is a subscription whose expiresDate is in the future.
| |
Verifying the StoreKit signature #
storekit.ts is the crypto half. It walks the x5c chain leaf to root, pins the root against the SHA-256 of Apple Root CA - G3, and verifies the JWS with the leaf key. The verifier is a typed function, so tests inject a fake and skip the certificate chain entirely. Its dependencies load lazily, so the pure decision above needs zero installs.
| |
Verifying App Attest #
appattest.ts is the second gate, active only when you configure it. The app attests a hardware-backed key once, the server stores that public key under a key identifier, and each request then carries an assertion the Worker checks. Like the StoreKit verifier, it is injectable so the tests run offline.
| |
The full procedure looks up the registered public key, decodes the assertion, confirms its rpIdHash binds to your App ID, checks the signature counter is strictly increasing, and verifies the signature. The example performs the structural checks and marks the one step that needs your key store, where a production deployment wires in Workers KV and a P-256 verification.
The proxy #
proxy.ts holds two deliberate narrowings. resolveUpstreamPath rejects any path you did not opt into, so an authorized caller reaches the Messages API and nothing else your key can touch. buildUpstreamHeaders constructs a fresh header set with exactly three entries, the content type, the Anthropic version, and the injected key, so cookies and arbitrary anthropic-beta flags the client sent never ride along.
| |
The worker #
worker.ts wires it together. It reads which gates are configured. If neither is, it returns 500 rather than acting as an open relay for your API bill. It resolves the upstream path, runs each configured gate, and requires every one of them to pass before injecting the key and forwarding. A failed check returns 401 or 403 and never reaches Anthropic.
| |
The tests exercise the real decision-and-proxy path with fake verifiers: an allowed bundle in Production passes, a sandbox transaction is turned away, a disallowed path is refused, an unconfigured Worker returns 500, and an entitled caller’s request reaches Anthropic carrying only the three headers it should.
| |
The Swift client #
On the device, the app holds no key. It pulls the current entitlement’s signed representation, builds an App Attest assertion when the device supports it, and routes Claude through the BFF with .proxied auth. The client splits one type per file too: the namespace, the error, the entitlement fetch, the attestation helper, and the header assembly.
| |
| |
| |
The header assembly is a pure function, so it is the one piece the client tests can exercise without StoreKit or a device. The assertion binds to the same request line the Worker reconstructs, so a lifted assertion cannot be replayed onto a different call.
| |
| |
What this does not do #
This walkthrough gates access. Three things it leaves open are worth naming, because the honest version of “a lapsed or refunded user does not get through” has caveats.
revocationDate from the JWS the client sends, so it only catches a refund the client honestly reports. A refunded user can replay a pre-refund JWS that lacks the field, and a refunded non-consumable’s old token has no expiresDate to age it out. Closing this needs the server side of the loop: App Store Server Notifications V2 for revocation events, or a periodic App Store Server API re-check, recording revocations server-side rather than trusting the token in hand.The same staleness drives replay. One JWS extracted from one entitled device feeds unbounded requests from any script, until the server learns to distrust it. App Attest narrows this to genuine app instances, but the durable fix is the same server-side revocation record.
model, cap max_tokens, and rate-limit per originalTransactionId with a KV counter or a Durable Object. The post’s companion section,
what the BFF still can’t do, covers the account-level backstop: workspace spend limits, per-key budgets, and key rotation.Both are out of scope here, and both are the natural next file in this same bundle.