Your API Key Doesn't Belong in the App
Table of Contents
Apple just made Claude a drop-in language model on its platforms. The Claude for Foundation Models Swift package conforms Claude to the Foundation Models framework’s LanguageModel protocol, so you drive it with the same LanguageModelSession API you already use for Apple’s on-device model: respond(to:), streaming, structured output, and tool calling, all unchanged. The quick start is five lines:
let model = ClaudeLanguageModel(
name: .opus4_8,
auth: .apiKey(ProcessInfo.processInfo.environment["ANTHROPIC_API_KEY"] ?? "")
)
let session = LanguageModelSession(model: model)
let response = try await session.respond(to: "Plan a 4-day trip to Buenos Aires.")
It works on the first run. It is also a trap. That API key is now a string in your shipping binary, and the rule that decides this is not negotiable: a credential that crosses an untrusted boundary is already burned.
A shipped secret is a leaked secret #
A mobile app is not a server. It is a file you hand to every user and every attacker, and they get to keep it. Anyone can pull the .ipa, run strings over the binary, decompile it, or sit a proxy in front of the network traffic. Obfuscation raises the cost of extraction; it never prevents it. Encrypting the secret on the device is the same bargain with a longer receipt: the key that decrypts it has to ship too, so you have moved the secret, not protected it. If the secret reaches the device, assume it is extractable.
This isn’t theory, and it gets expensive. I pulled the evidence together across two decades of breaches, and the same failure repeats:
- Uber, 2016. Hardcoded credentials handed attackers an AWS key and the personal data of 57 million riders and drivers. The tab: a $148 million multistate settlement, an FTC consent order, and a criminal conviction of the company’s chief security officer for the cover-up, affirmed on appeal in 2025.
- Symantec, 2022. A scan found 1,859 apps shipping hardcoded AWS credentials; roughly three in four held live tokens. One banking SDK exposed over 300,000 biometric fingerprints.
- CloudSEK, 2022. 3,207 apps were leaking Twitter API keys, and 230 of them carried all four OAuth credentials, enough for full account takeover.
This isn’t a relic of the last decade. In 2026, Truffle Security found 2,863 live Google API keys scraped from public sites that had silently gained access to Gemini, billable to whoever owned them. New model, same mistake. The pattern doesn’t end; it only changes which key is worth stealing.
Anthropic says the same thing in its own documentation. The development-only .apiKey mode carries an explicit warning that a bundled key is extractable from the shipping binary, and the production answer is a proxy:
Your proxy receives standard Messages API requests, attaches the
x-api-keyheader, and forwards them tohttps://api.anthropic.com.
The Claude key is exactly this class of secret. So build that proxy, and make it earn its keep.
The proxy is your boundary: call it a BFF #
The fix is structural. Move the secret to a server you control, and give the app nothing but a URL. The app talks to your endpoint; your endpoint talks to Anthropic. The key never leaves your infrastructure.
This pattern has a name: the Backend for Frontend, or BFF. A BFF is a thin backend dedicated to one client. It owns the credentials, shapes and authorizes the calls, and hands the frontend exactly what it needs and nothing it shouldn’t have. For our purposes the job is narrow: hold the Anthropic key, decide who’s allowed to spend it, and proxy the Messages API.
Where you run that BFF matters. A regional origin server puts a single round-trip city between every user and every token. Cloudflare Workers run the same logic in 300-plus cities, so the credential injection happens a few milliseconds from the caller wherever they are. That is the difference between a proxy bolted onto one data center and a boundary that exists everywhere your users do. A BFF at the edge.
Gate the proxy with the purchase #
Most proxy tutorials skip the next part. A bare proxy that injects your key is an open relay for your API bill. Anyone who finds the URL can spend your money. The proxy has to answer one question before it forwards anything: is this caller entitled to use Claude?
For a paid app, the answer is the in-app purchase. StoreKit 2 hands the app a cryptographically signed transaction: a JWS whose x5c header carries Apple’s certificate chain. The app forwards that JWS to the BFF in a header. The BFF verifies the signature against Apple’s root, confirms the transaction is for one of your apps, and checks that the entitlement is live. No round-trip to Apple in the request path; the signature is self-contained.
Trial and paid collapse into the same check. A free trial is just a subscription whose expiresDate is in the future, so “is this entitlement current?” covers both. A trial user and a paying user clear the same gate; a lapsed or refunded one does not.
The Worker #
The whole BFF is one file. It reads the signed transaction from the X-IAP-Transaction header, verifies it, checks the transaction’s bundleId against an allowlist you set in Worker configuration, confirms the entitlement isn’t revoked or expired, and only then injects x-api-key and forwards the request upstream. A failed check returns 401/403 and never touches Anthropic.
/**
* Backend-for-Frontend (BFF) Cloudflare Worker — an in-app-purchase-gated
* Claude proxy.
*
* EXAMPLE CODE. Not deployed. Placeholders only — no real keys, bundle IDs,
* account IDs, or Worker URLs. See README.md.
*
* Flow:
* 1. The app sends a StoreKit 2 signed transaction (JWS) in the
* X-IAP-Transaction header, alongside a standard Anthropic Messages API
* request body.
* 2. This Worker verifies the JWS against Apple's certificate chain, checks
* the transaction's bundleId against an allowlist, and confirms the
* entitlement is current (not revoked, not expired). A free trial is just
* a subscription with a future expiresDate, so one check covers both.
* 3. On success it injects the Anthropic API key server-side and forwards the
* request to https://api.anthropic.com. The app ships no key.
*/
export interface Env {
/** Anthropic API key. Set as a secret: `wrangler secret put ANTHROPIC_API_KEY`. */
ANTHROPIC_API_KEY: string;
/** Comma-separated allowlist of app bundle IDs, e.g. "com.example.app". */
ALLOWED_BUNDLE_IDS: string;
/** Comma-separated allowlist of product IDs. Empty/unset = accept any product. */
ALLOWED_PRODUCT_IDS?: string;
/** Hex SHA-256 of the DER-encoded "Apple Root CA - G3" certificate.
* Obtain and verify from https://www.apple.com/certificateauthority/ */
APPLE_ROOT_CA_SHA256: string;
/** Anthropic API version header. Defaults to "2023-06-01". */
ANTHROPIC_VERSION?: string;
}
const ANTHROPIC_BASE = "https://api.anthropic.com";
const TRANSACTION_HEADER = "X-IAP-Transaction";
const DEFAULT_ANTHROPIC_VERSION = "2023-06-01";
/** The decoded StoreKit 2 transaction fields this gate cares about. */
export interface JWSTransaction {
bundleId?: string;
productId?: string;
/** Subscription expiry, ms since epoch. Absent for non-subscription products. */
expiresDate?: number;
/** Set (ms since epoch) when Apple revokes the purchase (refund, dispute). */
revocationDate?: number;
}
export interface GateConfig {
allowedBundleIds: Set<string>;
allowedProductIds: Set<string> | null; // null = any product
now: number; // ms since epoch
}
export type GateResult =
| { ok: true }
| { ok: false; status: 401 | 403; reason: string };
/** Parse a comma-separated env var into a trimmed, non-empty Set. */
export function parseList(value: string | undefined): Set<string> {
return new Set(
(value ?? "")
.split(",")
.map((s) => s.trim())
.filter((s) => s.length > 0),
);
}
/**
* Pure entitlement decision — no crypto, no network. Given a decoded
* transaction and the gate config, decide whether the caller is entitled.
*/
export function evaluateEntitlement(
txn: JWSTransaction,
cfg: GateConfig,
): GateResult {
if (!txn.bundleId || !cfg.allowedBundleIds.has(txn.bundleId)) {
return { ok: false, status: 403, reason: "bundle_id_not_allowed" };
}
if (
cfg.allowedProductIds &&
(!txn.productId || !cfg.allowedProductIds.has(txn.productId))
) {
return { ok: false, status: 403, reason: "product_id_not_allowed" };
}
if (txn.revocationDate != null) {
return { ok: false, status: 403, reason: "entitlement_revoked" };
}
// Trial and paid are the same check: a subscription is valid while its
// expiresDate is in the future. Non-subscription products omit expiresDate.
if (txn.expiresDate != null && txn.expiresDate <= cfg.now) {
return { ok: false, status: 403, reason: "entitlement_expired" };
}
return { ok: true };
}
/** Verifier signature, so tests can swap in a fake and skip real certificates. */
export type TransactionVerifier = (
jws: string,
appleRootSha256: string,
) => Promise<JWSTransaction>;
/**
* Verify a StoreKit 2 signed transaction:
* - the x5c chain is internally consistent (leaf <- intermediate <- root),
* - the root's SHA-256 matches the pinned Apple Root CA - G3, and
* - the JWS is signed by the leaf certificate.
*
* Dependencies are imported lazily so the pure logic above is unit-testable
* with zero installs. Production dependencies: `jose`, `@peculiar/x509`.
*/
export const verifyTransactionJWS: TransactionVerifier = async (
jws,
appleRootSha256,
) => {
const { decodeProtectedHeader, importX509, jwtVerify } = await import("jose");
const x509 = await import("@peculiar/x509");
const header = decodeProtectedHeader(jws);
const x5c = header.x5c;
if (!Array.isArray(x5c) || x5c.length < 2) {
throw new Error("JWS is missing an x5c certificate chain");
}
// Verify each link in the chain (leaf first, root last).
const certs = x5c.map((der) => new x509.X509Certificate(der));
for (let i = 0; i < certs.length - 1; i++) {
const issuerKey = await certs[i + 1].publicKey.export();
if (!(await certs[i].verify({ publicKey: issuerKey }))) {
throw new Error("broken certificate chain");
}
}
// Pin the trust anchor: the root must be Apple Root CA - G3.
const root = certs[certs.length - 1];
const rootSha256 = toHex(await crypto.subtle.digest("SHA-256", root.rawData));
if (rootSha256 !== normalizeHex(appleRootSha256)) {
throw new Error("untrusted root certificate");
}
// Verify the JWS signature with the leaf certificate's public key.
const leafKey = await importX509(certs[0].toString("pem"), "ES256");
const { payload } = await jwtVerify(jws, leafKey);
return payload as JWSTransaction;
};
function toHex(buf: ArrayBuffer): string {
return [...new Uint8Array(buf)]
.map((b) => b.toString(16).padStart(2, "0"))
.join("");
}
function normalizeHex(s: string): string {
return s.toLowerCase().replace(/[^0-9a-f]/g, "");
}
/**
* Core request handler. The verifier is injectable so tests can exercise the
* gate-and-proxy flow without real Apple certificates.
*/
export async function handleRequest(
request: Request,
env: Env,
verify: TransactionVerifier = verifyTransactionJWS,
): Promise<Response> {
if (request.method !== "POST") {
return new Response("method not allowed", { status: 405 });
}
const jws = request.headers.get(TRANSACTION_HEADER);
if (!jws) {
return json(401, { error: "missing in-app purchase transaction" });
}
let txn: JWSTransaction;
try {
txn = await verify(jws, env.APPLE_ROOT_CA_SHA256);
} catch {
// Bad signature, broken chain, or untrusted root — reject without detail.
return json(401, { error: "invalid in-app purchase transaction" });
}
const gate = evaluateEntitlement(txn, {
allowedBundleIds: parseList(env.ALLOWED_BUNDLE_IDS),
allowedProductIds: env.ALLOWED_PRODUCT_IDS
? parseList(env.ALLOWED_PRODUCT_IDS)
: null,
now: Date.now(),
});
if (!gate.ok) {
return json(gate.status, { error: gate.reason });
}
// Entitled. Inject the secret server-side and forward to Anthropic.
const incoming = new URL(request.url);
const target = ANTHROPIC_BASE + incoming.pathname + incoming.search;
const headers = new Headers(request.headers);
headers.delete(TRANSACTION_HEADER); // never forward the app token upstream
headers.delete("host");
headers.set("x-api-key", env.ANTHROPIC_API_KEY);
headers.set(
"anthropic-version",
env.ANTHROPIC_VERSION ?? DEFAULT_ANTHROPIC_VERSION,
);
return fetch(target, {
method: request.method,
headers,
body: request.body,
// @ts-expect-error duplex is required when streaming a request body
duplex: "half",
});
}
function json(status: number, body: unknown): Response {
return new Response(JSON.stringify(body), {
status,
headers: { "content-type": "application/json" },
});
}
export default {
fetch(request: Request, env: Env): Promise<Response> {
return handleRequest(request, env);
},
};
The configuration is the whole operational story: two public variables plus one secret that never appears in source control. ALLOWED_BUNDLE_IDS lists the apps you trust, and APPLE_ROOT_CA_SHA256 pins the fingerprint of Apple Root CA - G3, taken from Apple’s certificate authority page:
[vars]
ALLOWED_BUNDLE_IDS = "com.example.app"
APPLE_ROOT_CA_SHA256 = "<pinned fingerprint>"
wrangler secret put ANTHROPIC_API_KEY
The signature verification leans on jose and @peculiar/x509 rather than hand-rolled crypto. The certificate chain and the JWS are exactly the kind of code you do not want to invent. The entitlement decision itself is a pure function, which makes it testable in isolation: the example ships a suite that signs synthetic transactions and asserts that an allowed bundle with a future expiry passes, while a wrong bundle, an expired subscription, a revoked purchase, and a missing signature each get turned away before a single upstream call.
App Attest: the other half, and the fallback #
In-app purchase answers “is this user entitled?” It does not answer “is this even my app talking?” Those are different questions, and the second one is where Apple’s App Attest fits. App Attest gives you a hardware-backed assertion that a request comes from a genuine, unmodified instance of your app on a real device, not a script replaying an extracted token.
The two gates complement each other. A paid app should want both: App Attest proves the caller is your real binary, and the IAP transaction proves that binary belongs to an entitled user. The BFF can check the attestation first and the entitlement second, and only a request that clears both gets a key.
App Attest is also the answer when there’s no purchase to gate. Plenty of apps are free, ad-supported, or aren’t monetizing the Claude feature, so there’s no IAP for the BFF to check. The goal there isn’t billing; it’s keeping scripts and extracted-key replays off the endpoint. Same BFF, same edge, same injected secret. You swap the IAP check for an App Attest verification and gate on authenticity instead of entitlement.
The app ships no key #
On the client, this is a one-line change from the trap we started with. The package’s .proxied auth mode points the session at your Worker and sends your authorization headers on every request, and the app holds no API key at all. You pull the current entitlement’s signed representation from StoreKit and pass it through:
// App-side helper for talking to Claude through an IAP-gated
// Backend-for-Frontend (BFF) Worker.
//
// EXAMPLE CODE. Placeholders only — point `baseURL` at your own Worker.
// Requires Xcode 27 / OS 27 betas and the ClaudeForFoundationModels package.
import Foundation
import StoreKit
import FoundationModels
import ClaudeForFoundationModels
// A caseless `enum` is Swift's idiomatic namespace: it groups the static
// members below and cannot be instantiated (unlike a `struct`, which can).
enum ClaudeBFF {
/// The BFF endpoint. Point this at your deployed Worker.
static let baseURL = URL(string: "https://bff.example.com/claude")!
/// Header the BFF reads the signed transaction from.
static let transactionHeader = "X-IAP-Transaction"
enum EntitlementError: Error {
case noActiveEntitlement
}
/// The user's current entitlement as a signed JWS, if one is active.
/// A free trial and a paid subscription both surface here while active, so
/// the app doesn't distinguish them — the BFF makes the final decision and
/// re-verifies the signature server-side.
static func currentEntitlementJWS() async throws -> String {
for await result in Transaction.currentEntitlements {
// Forward the signed representation as-is. We don't treat the
// device's own verification as the gate — the server does.
return result.jwsRepresentation
}
throw EntitlementError.noActiveEntitlement
}
/// Headers sent to the BFF. The app ships no API key; this header is how the
/// Worker authorizes the caller before injecting the real credential.
static func proxyHeaders(jws: String) -> [String: String] {
[transactionHeader: jws]
}
/// A Claude model that routes through the BFF using `.proxied` auth.
static func model(
_ name: ClaudeModel = .opus4_8,
jws: String
) -> ClaudeLanguageModel {
ClaudeLanguageModel(
name: name,
auth: .proxied(headers: proxyHeaders(jws: jws)),
baseURL: baseURL
)
}
/// Convenience: fetch the entitlement, build a session, and respond.
static func respond(to prompt: String) async throws -> String {
let jws = try await currentEntitlementJWS()
let session = LanguageModelSession(model: model(jws: jws))
let response = try await session.respond(to: prompt)
return response.content
}
}
The LanguageModelSession on top of this is identical to the one you’d write against Apple’s on-device model. The proxy is invisible to the rest of the app: a base URL and a header. The secret lives at the edge, where it belongs.
One pattern, every client #
Claude on Apple’s framework is the worked example, but the shape is general, and that’s the point of this series. Any secret you’re tempted to ship in a client, whether a mobile binary, a single-page app, or a desktop bundle, belongs behind a BFF instead. One place holds the credential. One place decides who’s authorized. One place meters, rate-limits, and rotates. Run it at the edge and that one place is everywhere at once.
The key was never supposed to be in the app. Put it where you can defend it.