Skip to main content
  1. Code/

A Gated Claude BFF at the Edge

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.

env.ts Raw Download
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
/**
 * Env: the Worker's configuration surface.
 *
 * EXAMPLE CODE. Not deployed. Placeholders only, no real keys, bundle IDs,
 * team IDs, account IDs, or Worker URLs. See README.md.
 *
 * Two independent gates can be turned on by configuration:
 *
 *   StoreKit  (entitlement): set ALLOWED_BUNDLE_IDS + APPLE_ROOT_CA_SHA256.
 *   App Attest (authenticity): set APP_ATTEST_TEAM_ID + APP_ATTEST_BUNDLE_ID.
 *
 * If neither is configured the Worker refuses to run (500). If both are
 * configured a request must clear both.
 */
export interface Env {
  /** Anthropic API key. Set as a secret: `wrangler secret put ANTHROPIC_API_KEY`. */
  ANTHROPIC_API_KEY: string;

  // --- StoreKit entitlement gate (active when both are set) ---

  /** 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;

  // --- App Attest authenticity gate (active when both are set) ---

  /** Apple Developer Team ID the app ships under, e.g. "ABCDE12345". */
  APP_ATTEST_TEAM_ID?: string;
  /** Bundle ID the attestation must be bound to, e.g. "com.example.app". */
  APP_ATTEST_BUNDLE_ID?: string;

  // --- Proxy configuration ---

  /** Comma-separated allowlist of upstream paths. Defaults to "/v1/messages". */
  ALLOWED_UPSTREAM_PATHS?: string;
  /** Anthropic API version header. Defaults to "2023-06-01". */
  ANTHROPIC_VERSION?: string;
}

export const ANTHROPIC_BASE = "https://api.anthropic.com";
export const DEFAULT_ANTHROPIC_VERSION = "2023-06-01";
export const DEFAULT_UPSTREAM_PATHS = "/v1/messages";

/** Header carrying the StoreKit 2 signed transaction (JWS). */
export const TRANSACTION_HEADER = "X-IAP-Transaction";
/** Header carrying the App Attest assertion (base64). */
export const ASSERTION_HEADER = "X-App-Attest-Assertion";
/** Header carrying the App Attest key identifier the assertion was signed with. */
export const KEY_ID_HEADER = "X-App-Attest-Key-Id";

The matching wrangler.toml carries the public variables and documents the one secret that never lands in source control.

wrangler.toml Raw Download
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
# Example Wrangler config for the gated Claude BFF Worker.
# EXAMPLE ONLY, placeholders. Not deployed.

name = "claude-bff-example"
main = "worker.ts"
compatibility_date = "2026-06-01"

# Public, non-secret configuration:
[vars]
# --- StoreKit entitlement gate (active when both are set) ---
# Comma-separated bundle IDs your app(s) ship under.
ALLOWED_BUNDLE_IDS = "com.example.app"
# Optional: restrict to specific product IDs. Empty = accept any product.
ALLOWED_PRODUCT_IDS = ""
# 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 = "PUT_THE_PINNED_FINGERPRINT_HERE"

# --- App Attest authenticity gate (active when both are set) ---
# Leave blank to run StoreKit only. Set both to require a genuine app instance.
APP_ATTEST_TEAM_ID = ""
APP_ATTEST_BUNDLE_ID = ""

# --- Proxy configuration ---
# Opt in to each upstream path the app may reach. Default is the Messages API.
ALLOWED_UPSTREAM_PATHS = "/v1/messages"
# Anthropic API version header.
ANTHROPIC_VERSION = "2023-06-01"

# Secret, never commit this; set it out of band:
#   wrangler secret put ANTHROPIC_API_KEY

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.

transaction.ts Raw Download
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
/**
 * JWSTransaction: the decoded StoreKit 2 transaction fields this gate cares
 * about. The full payload carries far more; we read only what the entitlement
 * decision needs.
 *
 * EXAMPLE CODE. Placeholders only.
 */
export interface JWSTransaction {
  bundleId?: string;
  productId?: string;
  /**
   * "Sandbox" or "Production". StoreKit 2 signs sandbox transactions with the
   * same certificate chain as production, and a sandbox Apple ID is free, so a
   * Production check is the only thing that stops anyone minting a valid
   * entitlement for nothing. The gate requires "Production".
   */
  environment?: "Sandbox" | "Production";
  /** 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;
}

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.

entitlement.ts Raw Download
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
/**
 * The entitlement decision: a pure function, no crypto and no network. Given a
 * decoded StoreKit 2 transaction and the gate config, decide whether the caller
 * is entitled. Keeping it pure makes it testable in isolation.
 *
 * EXAMPLE CODE. Placeholders only.
 */
import type { JWSTransaction } from "./transaction.ts";

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),
  );
}

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" };
  }
  // A sandbox transaction is signed by the same chain as production and costs
  // nothing to mint, so it must never clear the gate.
  if (txn.environment !== "Production") {
    return { ok: false, status: 403, reason: "environment_not_production" };
  }
  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 };
}

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.

storekit.ts Raw Download
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
/**
 * StoreKit 2 signed-transaction verification.
 *
 * EXAMPLE CODE. Placeholders only.
 *
 * Verify that:
 *   - 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.
 *
 * The crypto dependencies are imported lazily so the pure logic in
 * entitlement.ts stays unit-testable with zero installs. Production
 * dependencies: `jose`, `@peculiar/x509`.
 */
import type { JWSTransaction } from "./transaction.ts";

/** Verifier signature, so tests can swap in a fake and skip real certificates. */
export type TransactionVerifier = (
  jws: string,
  appleRootSha256: string,
) => Promise<JWSTransaction>;

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, "");
}

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.

appattest.ts Raw Download
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
/**
 * App Attest assertion verification.
 *
 * EXAMPLE CODE. Placeholders only.
 *
 * StoreKit answers "is this user entitled?" App Attest answers "is this even my
 * app talking?" The app attests a hardware-backed key once (the public key is
 * stored server-side, keyed by keyId), then signs an assertion over each
 * request. This file verifies that per-request assertion.
 *
 * The verifier is injectable, mirroring storekit.ts, so tests run offline with
 * a fake in place of a real device assertion and a stored public key.
 *
 * A production verifier:
 *   1. looks up the registered public key for keyId (e.g. in Workers KV),
 *   2. CBOR-decodes the assertion into { signature, authenticatorData },
 *   3. checks rpIdHash === SHA-256("<teamId>.<bundleId>"),
 *   4. checks the signature counter is strictly increasing (replay defense),
 *   5. verifies signature over (authenticatorData || clientDataHash) with the
 *      stored P-256 public key.
 * Apple documents the full procedure under DeviceCheck / App Attest.
 */
import type { Env } from "./env.ts";

export interface AppAttestConfig {
  teamId: string;
  bundleId: string;
}

export interface AppAttestAssertion {
  keyId: string;
  /** Base64 CBOR assertion produced by DCAppAttestService.generateAssertion. */
  assertion: string;
  /** SHA-256 of the exact request payload the client signed over. */
  clientDataHash: ArrayBuffer;
}

/** Resolves if the assertion is authentic; throws otherwise. */
export type AppAttestVerifier = (
  assertion: AppAttestAssertion,
  cfg: AppAttestConfig,
) => Promise<void>;

/** App Attest is "configured" only when both identity values are present. */
export function appAttestConfig(env: Env): AppAttestConfig | null {
  if (!env.APP_ATTEST_TEAM_ID || !env.APP_ATTEST_BUNDLE_ID) return null;
  return {
    teamId: env.APP_ATTEST_TEAM_ID,
    bundleId: env.APP_ATTEST_BUNDLE_ID,
  };
}

export const verifyAppAttestAssertion: AppAttestVerifier = async (
  assertion,
  cfg,
) => {
  if (!assertion.keyId || !assertion.assertion) {
    throw new Error("missing App Attest assertion");
  }

  // Structural check we can do without the stored key: the assertion must
  // CBOR-decode into authenticatorData whose rpIdHash binds to this App ID.
  const cbor = await import("cbor-x");
  const decoded = cbor.decode(base64ToBytes(assertion.assertion)) as {
    authenticatorData?: Uint8Array;
    signature?: Uint8Array;
  };
  if (!decoded.authenticatorData || !decoded.signature) {
    throw new Error("malformed App Attest assertion");
  }

  const rpIdHash = decoded.authenticatorData.slice(0, 32);
  const appId = `${cfg.teamId}.${cfg.bundleId}`;
  const expected = new Uint8Array(
    await crypto.subtle.digest("SHA-256", new TextEncoder().encode(appId)),
  );
  if (!bytesEqual(rpIdHash, expected)) {
    throw new Error("App Attest rpId does not match this app");
  }

  // The signature itself is verified against the public key registered during
  // the one-time attestation step, looked up by keyId. Wire your key store and
  // P-256 verification here. Until then, treat an unregistered key as untrusted.
  throw new Error("App Attest key store not configured");
};

function base64ToBytes(b64: string): Uint8Array {
  const bin = atob(b64);
  const out = new Uint8Array(bin.length);
  for (let i = 0; i < bin.length; i++) out[i] = bin.charCodeAt(i);
  return out;
}

function bytesEqual(a: Uint8Array, b: Uint8Array): boolean {
  if (a.length !== b.length) return false;
  let diff = 0;
  for (let i = 0; i < a.length; i++) diff |= a[i] ^ b[i];
  return diff === 0;
}

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.

proxy.ts Raw Download
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
/**
 * The proxy: decide which upstream path is allowed, build a clean header set,
 * and forward to Anthropic.
 *
 * EXAMPLE CODE. Placeholders only.
 *
 * Two deliberate narrowings live here. First, an entitled caller may reach only
 * the upstream paths you opt into (default "/v1/messages"), not any Anthropic
 * endpoint your key can touch. Second, the upstream request carries only the
 * three headers it needs, not whatever the client sent (cookies, arbitrary
 * anthropic-beta flags, the app token).
 */
import {
  ANTHROPIC_BASE,
  DEFAULT_ANTHROPIC_VERSION,
  type Env,
} from "./env.ts";

/** Resolve the upstream path, or null when the requested path is not allowed. */
export function resolveUpstreamPath(
  pathname: string,
  allowed: Set<string>,
): string | null {
  return allowed.has(pathname) ? pathname : null;
}

/**
 * Build the only headers the upstream call needs. Nothing the client sent is
 * forwarded by default: we copy content-type, set the Anthropic version, and
 * inject the key.
 */
export function buildUpstreamHeaders(request: Request, env: Env): Headers {
  const headers = new Headers();
  const contentType = request.headers.get("content-type");
  if (contentType) headers.set("content-type", contentType);
  headers.set(
    "anthropic-version",
    env.ANTHROPIC_VERSION ?? DEFAULT_ANTHROPIC_VERSION,
  );
  headers.set("x-api-key", env.ANTHROPIC_API_KEY);
  return headers;
}

/** Forward the (already authorized) request to Anthropic. */
export function forwardToAnthropic(
  request: Request,
  env: Env,
  upstreamPath: string,
): Promise<Response> {
  return fetch(ANTHROPIC_BASE + upstreamPath, {
    method: request.method,
    headers: buildUpstreamHeaders(request, env),
    body: request.body,
    // @ts-expect-error duplex is required when streaming a request body
    duplex: "half",
  });
}

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.

worker.ts Raw Download
  1
  2
  3
  4
  5
  6
  7
  8
  9
 10
 11
 12
 13
 14
 15
 16
 17
 18
 19
 20
 21
 22
 23
 24
 25
 26
 27
 28
 29
 30
 31
 32
 33
 34
 35
 36
 37
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
/**
 * Backend-for-Frontend (BFF) Cloudflare Worker, an in-app-purchase- and/or
 * App-Attest-gated Claude proxy.
 *
 * EXAMPLE CODE. Not deployed. Placeholders only. See README.md.
 *
 * Flow:
 *   1. The app sends a standard Anthropic Messages API request, plus a StoreKit
 *      2 signed transaction (X-IAP-Transaction) and/or an App Attest assertion
 *      (X-App-Attest-Assertion + X-App-Attest-Key-Id).
 *   2. Each gate that is configured must pass: StoreKit proves the caller is
 *      entitled, App Attest proves the caller is a genuine instance of your app.
 *      If neither gate is configured the Worker refuses to run (500).
 *   3. On success it injects the Anthropic key server-side and forwards the
 *      request to an allowlisted upstream path. The app ships no key.
 */
import {
  ASSERTION_HEADER,
  DEFAULT_UPSTREAM_PATHS,
  KEY_ID_HEADER,
  TRANSACTION_HEADER,
  type Env,
} from "./env.ts";
import {
  evaluateEntitlement,
  parseList,
  type GateResult,
} from "./entitlement.ts";
import {
  verifyTransactionJWS,
  type TransactionVerifier,
} from "./storekit.ts";
import {
  appAttestConfig,
  verifyAppAttestAssertion,
  type AppAttestVerifier,
} from "./appattest.ts";
import { forwardToAnthropic, resolveUpstreamPath } from "./proxy.ts";
import type { JWSTransaction } from "./transaction.ts";

/** Verifiers are injectable so tests exercise the flow without real crypto. */
export interface Handlers {
  verifyTransaction: TransactionVerifier;
  verifyAppAttest: AppAttestVerifier;
}

const defaultHandlers: Handlers = {
  verifyTransaction: verifyTransactionJWS,
  verifyAppAttest: verifyAppAttestAssertion,
};

function storeKitConfig(env: Env) {
  if (!env.ALLOWED_BUNDLE_IDS || !env.APPLE_ROOT_CA_SHA256) return null;
  return {
    allowedBundleIds: parseList(env.ALLOWED_BUNDLE_IDS),
    allowedProductIds: env.ALLOWED_PRODUCT_IDS
      ? parseList(env.ALLOWED_PRODUCT_IDS)
      : null,
    rootSha256: env.APPLE_ROOT_CA_SHA256,
  };
}

export async function handleRequest(
  request: Request,
  env: Env,
  handlers: Handlers = defaultHandlers,
): Promise<Response> {
  if (request.method !== "POST") {
    return new Response("method not allowed", { status: 405 });
  }

  const storeKit = storeKitConfig(env);
  const appAttest = appAttestConfig(env);

  // A Worker with no gate configured is an open relay for your API bill. Refuse
  // to run rather than forward anything.
  if (!storeKit && !appAttest) {
    return json(500, { error: "no entitlement gate configured" });
  }

  const incoming = new URL(request.url);
  const upstreamPath = resolveUpstreamPath(
    incoming.pathname,
    parseList(env.ALLOWED_UPSTREAM_PATHS ?? DEFAULT_UPSTREAM_PATHS),
  );
  if (!upstreamPath) {
    return json(403, { error: "upstream path not allowed" });
  }

  const jws = request.headers.get(TRANSACTION_HEADER);

  // StoreKit gate (if configured): verify the signed transaction, then decide.
  if (storeKit) {
    if (!jws) {
      return json(401, { error: "missing in-app purchase transaction" });
    }
    let txn: JWSTransaction;
    try {
      txn = await handlers.verifyTransaction(jws, storeKit.rootSha256);
    } catch {
      return json(401, { error: "invalid in-app purchase transaction" });
    }
    const gate: GateResult = evaluateEntitlement(txn, {
      allowedBundleIds: storeKit.allowedBundleIds,
      allowedProductIds: storeKit.allowedProductIds,
      now: Date.now(),
    });
    if (!gate.ok) {
      return json(gate.status, { error: gate.reason });
    }
  }

  // App Attest gate (if configured): prove the caller is a genuine app instance.
  if (appAttest) {
    const assertion = request.headers.get(ASSERTION_HEADER);
    const keyId = request.headers.get(KEY_ID_HEADER);
    if (!assertion || !keyId) {
      return json(401, { error: "missing App Attest assertion" });
    }
    try {
      // Bind the assertion to the request line and the IAP token, so it cannot
      // be lifted onto a different call. The body is left untouched for forwarding.
      const clientData = `${request.method}\n${upstreamPath}\n${jws ?? ""}`;
      const clientDataHash = await crypto.subtle.digest(
        "SHA-256",
        new TextEncoder().encode(clientData),
      );
      await handlers.verifyAppAttest(
        { keyId, assertion, clientDataHash },
        appAttest,
      );
    } catch {
      return json(401, { error: "invalid App Attest assertion" });
    }
  }

  // Every configured gate passed. Inject the secret and forward upstream.
  return forwardToAnthropic(request, env, upstreamPath);
}

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 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.

worker.test.ts Raw Download
  1
  2
  3
  4
  5
  6
  7
  8
  9
 10
 11
 12
 13
 14
 15
 16
 17
 18
 19
 20
 21
 22
 23
 24
 25
 26
 27
 28
 29
 30
 31
 32
 33
 34
 35
 36
 37
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
/**
 * Tests for the IAP- and App-Attest-gated Claude BFF Worker.
 *
 * Run offline, zero installs:
 *   node --experimental-strip-types --test worker.test.ts
 *
 * The crypto verifiers are injectable, so these tests exercise the real
 * decision-and-proxy logic, the part that decides who gets through and what
 * gets forwarded, without Apple's certificate chain or a real device.
 */
import { test } from "node:test";
import assert from "node:assert/strict";
import { parseList, evaluateEntitlement } from "./entitlement.ts";
import type { JWSTransaction } from "./transaction.ts";
import type { TransactionVerifier } from "./storekit.ts";
import type { AppAttestVerifier } from "./appattest.ts";
import { handleRequest, type Handlers } from "./worker.ts";
import type { Env } from "./env.ts";

const baseCfg = () => ({
  allowedBundleIds: new Set(["com.example.app"]),
  allowedProductIds: null as Set<string> | null,
  now: 1_000_000,
});

const prod = (extra: Partial<JWSTransaction> = {}): JWSTransaction => ({
  bundleId: "com.example.app",
  environment: "Production",
  ...extra,
});

test("parseList trims and drops empties", () => {
  assert.deepEqual([...parseList(" a , b ,, c ")], ["a", "b", "c"]);
  assert.equal(parseList(undefined).size, 0);
});

test("entitlement: allowed bundle + Production + future expiry passes", () => {
  assert.deepEqual(
    evaluateEntitlement(prod({ expiresDate: 2_000_000 }), baseCfg()),
    { ok: true },
  );
});

test("entitlement: non-subscription (no expiresDate) passes", () => {
  assert.deepEqual(evaluateEntitlement(prod(), baseCfg()), { ok: true });
});

test("entitlement: sandbox environment is rejected (403)", () => {
  const txn = prod({ environment: "Sandbox", expiresDate: 2_000_000 });
  assert.deepEqual(evaluateEntitlement(txn, baseCfg()), {
    ok: false,
    status: 403,
    reason: "environment_not_production",
  });
});

test("entitlement: missing environment is rejected (403)", () => {
  const txn: JWSTransaction = { bundleId: "com.example.app" };
  assert.deepEqual(evaluateEntitlement(txn, baseCfg()), {
    ok: false,
    status: 403,
    reason: "environment_not_production",
  });
});

test("entitlement: wrong bundle is rejected (403)", () => {
  const txn = prod({ bundleId: "com.evil.app", expiresDate: 2_000_000 });
  assert.deepEqual(evaluateEntitlement(txn, baseCfg()), {
    ok: false,
    status: 403,
    reason: "bundle_id_not_allowed",
  });
});

test("entitlement: expired subscription is rejected (403)", () => {
  const txn = prod({ expiresDate: 500_000 });
  assert.deepEqual(evaluateEntitlement(txn, baseCfg()), {
    ok: false,
    status: 403,
    reason: "entitlement_expired",
  });
});

test("entitlement: revoked purchase is rejected (403)", () => {
  const txn = prod({ expiresDate: 2_000_000, revocationDate: 600_000 });
  assert.deepEqual(evaluateEntitlement(txn, baseCfg()), {
    ok: false,
    status: 403,
    reason: "entitlement_revoked",
  });
});

test("entitlement: product allowlist is enforced when set", () => {
  const cfg = { ...baseCfg(), allowedProductIds: new Set(["pro.monthly"]) };
  assert.equal(
    evaluateEntitlement(prod({ productId: "pro.monthly" }), cfg).ok,
    true,
  );
  assert.deepEqual(evaluateEntitlement(prod({ productId: "free.tier" }), cfg), {
    ok: false,
    status: 403,
    reason: "product_id_not_allowed",
  });
});

// --- handleRequest integration ---

const storeKitEnv: Env = {
  ANTHROPIC_API_KEY: "sk-ant-PLACEHOLDER",
  ALLOWED_BUNDLE_IDS: "com.example.app",
  APPLE_ROOT_CA_SHA256: "00",
};

function messagesRequest(
  headers: Record<string, string>,
  path = "/v1/messages",
): Request {
  return new Request(`https://bff.example.com${path}`, {
    method: "POST",
    headers: { "content-type": "application/json", ...headers },
    body: JSON.stringify({ model: "claude-opus-4-8", max_tokens: 16, messages: [] }),
  });
}

const passTxn: TransactionVerifier = async () =>
  prod({ expiresDate: Date.now() + 60_000 });

function handlers(over: Partial<Handlers> = {}): Handlers {
  return {
    verifyTransaction: passTxn,
    verifyAppAttest: async () => {},
    ...over,
  };
}

test("handleRequest: no gate configured → 500, no upstream call", async () => {
  const res = await handleRequest(
    messagesRequest({ "X-IAP-Transaction": "ok" }),
    { ANTHROPIC_API_KEY: "sk-ant-PLACEHOLDER" },
    handlers(),
  );
  assert.equal(res.status, 500);
});

test("handleRequest: disallowed upstream path → 403", async () => {
  const res = await handleRequest(
    messagesRequest({ "X-IAP-Transaction": "ok" }, "/v1/models"),
    storeKitEnv,
    handlers(),
  );
  assert.equal(res.status, 403);
});

test("handleRequest: missing transaction header → 401, verifier never called", async () => {
  let called = false;
  const res = await handleRequest(
    messagesRequest({}),
    storeKitEnv,
    handlers({
      verifyTransaction: async () => {
        called = true;
        return prod();
      },
    }),
  );
  assert.equal(res.status, 401);
  assert.equal(called, false);
});

test("handleRequest: invalid JWS → 401, no upstream call", async () => {
  const calls: string[] = [];
  const orig = globalThis.fetch;
  globalThis.fetch = (async (u: unknown) => {
    calls.push(String(u));
    return new Response("x");
  }) as typeof fetch;
  try {
    const res = await handleRequest(
      messagesRequest({ "X-IAP-Transaction": "bad" }),
      storeKitEnv,
      handlers({
        verifyTransaction: async () => {
          throw new Error("bad signature");
        },
      }),
    );
    assert.equal(res.status, 401);
    assert.equal(calls.length, 0);
  } finally {
    globalThis.fetch = orig;
  }
});

test("handleRequest: unentitled caller → 403, no upstream call", async () => {
  const calls: string[] = [];
  const orig = globalThis.fetch;
  globalThis.fetch = (async (u: unknown) => {
    calls.push(String(u));
    return new Response("x");
  }) as typeof fetch;
  try {
    const res = await handleRequest(
      messagesRequest({ "X-IAP-Transaction": "ok" }),
      storeKitEnv,
      handlers({ verifyTransaction: async () => prod({ bundleId: "com.evil.app" }) }),
    );
    assert.equal(res.status, 403);
    assert.equal(calls.length, 0);
  } finally {
    globalThis.fetch = orig;
  }
});

test("handleRequest: entitled caller → forwards with a clean, injected header set", async () => {
  const orig = globalThis.fetch;
  let target = "";
  let sent: Headers | null = null;
  globalThis.fetch = (async (u: unknown, init: RequestInit) => {
    target = String(u);
    sent = new Headers(init.headers);
    return new Response(JSON.stringify({ ok: true }), { status: 200 });
  }) as typeof fetch;
  try {
    const res = await handleRequest(
      messagesRequest({
        "X-IAP-Transaction": "ok",
        cookie: "session=abc",
        "anthropic-beta": "evil-flag",
      }),
      storeKitEnv,
      handlers(),
    );
    assert.equal(res.status, 200);
    assert.equal(target, "https://api.anthropic.com/v1/messages");
    const h = sent as unknown as Headers;
    assert.equal(h.get("x-api-key"), "sk-ant-PLACEHOLDER");
    assert.equal(h.get("anthropic-version"), "2023-06-01");
    assert.equal(h.get("content-type"), "application/json");
    // Nothing the client sent leaks upstream.
    assert.equal(h.get("X-IAP-Transaction"), null);
    assert.equal(h.get("cookie"), null);
    assert.equal(h.get("anthropic-beta"), null);
  } finally {
    globalThis.fetch = orig;
  }
});

// --- App Attest gate ---

const appAttestEnv: Env = {
  ANTHROPIC_API_KEY: "sk-ant-PLACEHOLDER",
  APP_ATTEST_TEAM_ID: "ABCDE12345",
  APP_ATTEST_BUNDLE_ID: "com.example.app",
};

test("handleRequest: App Attest only, missing assertion → 401", async () => {
  const res = await handleRequest(
    messagesRequest({}),
    appAttestEnv,
    handlers(),
  );
  assert.equal(res.status, 401);
});

test("handleRequest: App Attest only, valid assertion → forwards", async () => {
  const orig = globalThis.fetch;
  let target = "";
  globalThis.fetch = (async (u: unknown) => {
    target = String(u);
    return new Response("{}", { status: 200 });
  }) as typeof fetch;
  try {
    const res = await handleRequest(
      messagesRequest({
        "X-App-Attest-Assertion": "ok",
        "X-App-Attest-Key-Id": "key-1",
      }),
      appAttestEnv,
      handlers(),
    );
    assert.equal(res.status, 200);
    assert.equal(target, "https://api.anthropic.com/v1/messages");
  } finally {
    globalThis.fetch = orig;
  }
});

test("handleRequest: App Attest failure → 401, no upstream call", async () => {
  const calls: string[] = [];
  const orig = globalThis.fetch;
  globalThis.fetch = (async (u: unknown) => {
    calls.push(String(u));
    return new Response("x");
  }) as typeof fetch;
  try {
    const failAttest: AppAttestVerifier = async () => {
      throw new Error("bad assertion");
    };
    const res = await handleRequest(
      messagesRequest({
        "X-App-Attest-Assertion": "bad",
        "X-App-Attest-Key-Id": "key-1",
      }),
      appAttestEnv,
      handlers({ verifyAppAttest: failAttest }),
    );
    assert.equal(res.status, 401);
    assert.equal(calls.length, 0);
  } finally {
    globalThis.fetch = orig;
  }
});

test("handleRequest: both gates configured, request must clear both", async () => {
  const orig = globalThis.fetch;
  globalThis.fetch = (async () =>
    new Response("{}", { status: 200 })) as typeof fetch;
  try {
    const bothEnv: Env = { ...storeKitEnv, ...appAttestEnv };
    // StoreKit passes, App Attest fails → rejected.
    const failAttest: AppAttestVerifier = async () => {
      throw new Error("bad assertion");
    };
    const rejected = await handleRequest(
      messagesRequest({
        "X-IAP-Transaction": "ok",
        "X-App-Attest-Assertion": "bad",
        "X-App-Attest-Key-Id": "key-1",
      }),
      bothEnv,
      handlers({ verifyAppAttest: failAttest }),
    );
    assert.equal(rejected.status, 401);

    // Both pass → forwarded.
    const ok = await handleRequest(
      messagesRequest({
        "X-IAP-Transaction": "ok",
        "X-App-Attest-Assertion": "ok",
        "X-App-Attest-Key-Id": "key-1",
      }),
      bothEnv,
      handlers(),
    );
    assert.equal(ok.status, 200);
  } finally {
    globalThis.fetch = orig;
  }
});

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.

ClaudeBFF.swift Raw Download
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
// Namespace for talking to Claude through a gated Backend-for-Frontend Worker.
//
// EXAMPLE CODE. Placeholders only, point baseURL at your own Worker.
// Requires Xcode 27 / OS 27 betas and the ClaudeForFoundationModels package.
//
// A caseless enum is Swift's idiomatic namespace: it groups the static members
// across these files and cannot be instantiated (unlike a struct, which can).
// One concern per file: entitlement, App Attest, headers, and errors each live
// in their own extension.

import Foundation
import FoundationModels
import ClaudeForFoundationModels

enum ClaudeBFF {
    /// The BFF endpoint host. The Foundation Models client appends the Messages
    /// path, so the Worker sees "/v1/messages", the one upstream path it allows.
    static let baseURL = URL(string: "https://bff.example.com")!

    /// Headers the BFF reads each credential from.
    static let transactionHeader = "X-IAP-Transaction"
    static let assertionHeader = "X-App-Attest-Assertion"
    static let keyIdHeader = "X-App-Attest-Key-Id"

    /// Convenience: gather credentials, build a session, and respond.
    static func respond(to prompt: String) async throws -> String {
        let session = LanguageModelSession(model: try await model())
        let response = try await session.respond(to: prompt)
        return response.content
    }
}
Entitlement.swift Raw Download
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
// StoreKit entitlement: pull the current entitlement's signed representation.
//
// EXAMPLE CODE. Placeholders only.

import Foundation
import StoreKit

extension ClaudeBFF {
    /// 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 does not 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. The device's own
            // verification is not the gate; the server's is.
            return result.jwsRepresentation
        }
        throw BFFError.noActiveEntitlement
    }
}
AppAttest.swift Raw Download
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
// App Attest: produce a per-request assertion proving this is a genuine,
// unmodified instance of the app on a real device.
//
// EXAMPLE CODE. Placeholders only.
//
// The key is generated and attested once; the attestation is registered with
// the BFF so it can store the public key under keyId (registration is out of
// scope here). Each request then carries an assertion the Worker verifies.

import Foundation
import CryptoKit
import DeviceCheck

extension ClaudeBFF {
    struct Assertion {
        let keyId: String
        let assertion: String // base64
    }

    /// Produce an App Attest assertion over the given client data, or nil when
    /// App Attest is unavailable (Simulator, unsupported device).
    static func appAttestAssertion(clientData: String) async throws -> Assertion? {
        let service = DCAppAttestService.shared
        guard service.isSupported else { return nil }

        let keyId = try await attestedKeyId(service)
        let hash = Data(SHA256.hash(data: Data(clientData.utf8)))
        let assertion = try await service.generateAssertion(keyId, clientDataHash: hash)
        return Assertion(keyId: keyId, assertion: assertion.base64EncodedString())
    }

    private static let keyIdDefaultsKey = "ClaudeBFF.appAttestKeyId"

    private static func attestedKeyId(_ service: DCAppAttestService) async throws -> String {
        if let saved = UserDefaults.standard.string(forKey: keyIdDefaultsKey) {
            return saved
        }
        let keyId = try await service.generateKey()
        // Attest the new key once, then register the attestation with your BFF
        // so it can store the public key for this keyId. Registration omitted.
        UserDefaults.standard.set(keyId, forKey: keyIdDefaultsKey)
        return keyId
    }
}

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.

ProxyHeaders.swift Raw Download
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
// Header assembly and the proxied model. The app ships no API key; these
// headers are how the Worker authorizes the caller before injecting the real
// credential.
//
// EXAMPLE CODE. Placeholders only.

import Foundation
import ClaudeForFoundationModels

extension ClaudeBFF {
    /// Pure header assembly, so it can be tested without StoreKit or a device.
    /// The App Attest assertion is bound to the same request line the Worker
    /// reconstructs: "POST\n/v1/messages\n<jws>".
    static func headers(jws: String, attestation: Assertion?) -> [String: String] {
        var headers = [transactionHeader: jws]
        if let attestation {
            headers[assertionHeader] = attestation.assertion
            headers[keyIdHeader] = attestation.keyId
        }
        return headers
    }

    /// Gather the entitlement JWS and (when available) an App Attest assertion,
    /// then assemble the proxy headers.
    static func proxyHeaders() async throws -> [String: String] {
        let jws = try await currentEntitlementJWS()
        let clientData = "POST\n/v1/messages\n\(jws)"
        let attestation = try await appAttestAssertion(clientData: clientData)
        return headers(jws: jws, attestation: attestation)
    }

    /// A Claude model that routes through the BFF using `.proxied` auth.
    static func model(_ name: ClaudeModel = .opus4_8) async throws -> ClaudeLanguageModel {
        ClaudeLanguageModel(
            name: name,
            auth: .proxied(headers: try await proxyHeaders()),
            baseURL: baseURL
        )
    }
}
BFFError.swift Raw Download
1
2
3
4
5
6
7
8
9
// Errors surfaced by the ClaudeBFF helper.
//
// EXAMPLE CODE. Placeholders only.

import Foundation

enum BFFError: Error {
    case noActiveEntitlement
}

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.

Refunds and revocation. The gate reads 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.

Spend controls. The gate decides who may call. It does not bound what a call costs. A single trial purchase buys unlimited Opus until you add limits: parse the body to allowlist 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.