Skip to content

· iOS Development  · 9 min read

Locking Down an LLM Proxy with App Attest (and the One Line of Crypto That Cost Me a Day)

How I used Apple App Attest and a Cloudflare Worker to build an LLM proxy that only my genuine iOS app, on a real device, can call—plus the seven gotchas that turned an afternoon into a debugging session.

How I used Apple App Attest and a Cloudflare Worker to build an LLM proxy that only my genuine iOS app, on a real device, can call—plus the seven gotchas that turned an afternoon into a debugging session.

Locking Down an LLM Proxy with App Attest (and the One Line of Crypto That Cost Me a Day)

If your app calls an LLM, you have a key worth money sitting somewhere. Put it in the app and someone will extract it and spend your money. The usual “fix”—a shared token baked into the binary, maybe XOR’d to feel clever—buys you about ten minutes against anyone with a disassembler.

I wanted something better for a small iOS app: a backend that only my real app, on a real device, could call. That’s exactly what Apple’s App Attest is for. This is how I wired it up end to end—a Cloudflare Worker proxy in front of the LLM API—and the handful of sharp edges that turned “should be an afternoon” into a proper debugging session. If you’re about to do this, read the gotchas section twice.


The shape of it

App Attest gives each install a key pair generated inside the device’s Secure Enclave. The private half never leaves the chip. Apple signs an attestation certifying “this is a genuine build of your App ID on genuine Apple hardware,” your server pins the public key, and from then on the app proves itself by signing challenges with that key.

That’s an identity primitive, not an authorization one. It answers “is this really my app?”—not “should this user get this?” and not “how much can they call me?” So the real design is three things working together:

LayerWhat it does
App AttestProves the caller is your genuine app on a real device
Session tokenA short-lived bearer token so you don’t re-verify crypto every call
Rate limit + capsBecause a real app on a real device can still hammer you

Architecturally:

iOS app  ──attest (once)──▶  Edge worker  ──▶  LLM API (key lives here)
         ──assertion → token──▶  (KV: pinned keys, nonces, counters)
         ──Bearer token + request──▶

The LLM key only ever exists on the worker. The app holds nothing reusable.


The protocol

Three phases. The first happens once per install; the second every ~15 minutes; the third on every request.

PHASE 1 — Attestation (once)
  keyId = generateKey()
  GET challenge
  attestation = attestKey(keyId, SHA256(challenge))
  POST /attest/verify → server validates Apple's cert chain, pins the public key

PHASE 2 — Session (periodic)
  GET challenge
  assertion = generateAssertion(keyId, SHA256(challenge))
  POST /session → server verifies the assertion, returns a short-lived HMAC token

PHASE 3 — Request (every call)
  POST /generate  Authorization: Bearer <token>
  server: verify token → rate-limit per device → cap input size → call the LLM

Why the session token instead of verifying an assertion on every request? Assertion verification is several async crypto operations plus a couple of KV reads. Doing that on every streaming LLM call adds real latency. Attest occasionally, mint a 15-minute token, then make many cheap token-checked calls.


The server (Cloudflare Worker)

Two dependencies:

npm i @peculiar/x509 reflect-metadata

@peculiar/x509 does the X.509 chain validation against Apple’s root using Web Crypto. (reflect-metadata is there for a reason you’ll appreciate in the gotchas.)

Verifying the attestation

This is the once-per-install heavy lift: decode the CBOR attestation, validate the certificate chain to Apple’s root, check the challenge made it into the cert, and pin the device’s public key.

import * as x509 from '@peculiar/x509';
x509.cryptoProvider.set(crypto);

// Apple App Attestation Root CA — public, safe to pin.
// https://www.apple.com/certificateauthority/Apple_App_Attestation_Root_CA.pem
const APPLE_ROOT_PEM = `-----BEGIN CERTIFICATE-----
MIICITCCAaegAwIBAgIQC/O+DvHN0uD7jG5yH2IXmDAKBggqhkjOPQQDAzBSMSYw
JAYDVQQDDB1BcHBsZSBBcHAgQXR0ZXN0YXRpb24gUm9vdCBDQTETMBEGA1UECgwK
QXBwbGUgSW5jLjETMBEGA1UECAwKQ2FsaWZvcm5pYTAeFw0yMDAzMTgxODMyNTNa
Fw00NTAzMTUwMDAwMDBaMFIxJjAkBgNVBAMMHUFwcGxlIEFwcCBBdHRlc3RhdGlv
biBSb290IENBMRMwEQYDVQQKDApBcHBsZSBJbmMuMRMwEQYDVQQIDApDYWxpZm9y
bmlhMHYwEAYHKoZIzj0CAQYFK4EEACIDYgAERTHhmLW07ATaFQIEVwTtT4dyctdh
NbJhFs/Ii2FdCgAHGbpphY3+d8qjuDngIN3WVhQUBHAoMeQ/cLiP1sOUtgjqK9au
Yen1mMEvRq9Sk3Jm5X8U62H+xTD3FE9TgS41o0IwQDAPBgNVHRMBAf8EBTADAQH/
MB0GA1UdDgQWBBSskRBTM72+aEH/pwyp5frq5eWKoTAOBgNVHQ8BAf8EBAMCAQYw
CgYIKoZIzj0EAwMDaAAwZQIwQgFGnByvsiVbpTKwSga0kP0e8EeDS4+sQmTvb7vn
53O5+FRXgeLhpJ06ysC5PrOyAjEAp5U4xDgEgllF7En3VcE3iexZZtKeYnpqtijV
oyFraWVIyd/dganmrduC1bmTBGwD
-----END CERTIFICATE-----`;

export async function verifyAttestation({ keyId, attestation, challenge, env }) {
  const { fmt, attStmt, authData } = decodeAttestation(b64urlDecode(attestation));
  if (fmt !== 'apple-appattest') throw err(400, 'Unexpected format');

  // authData: relying-party id, fresh counter, AAGUID, credential id == keyId
  if (!bytesEqual(authData.slice(0, 32), await sha256(enc(env.APP_ID)))) throw err(401, 'rpId mismatch');
  if (readUInt32BE(authData, 33) !== 0) throw err(401, 'counter must be 0');

  // Chain: leaf ← intermediate ← Apple root
  const [leafDer, intDer] = attStmt.x5c;
  const leaf = new x509.X509Certificate(leafDer);
  const int  = new x509.X509Certificate(intDer);
  const root = new x509.X509Certificate(APPLE_ROOT_PEM);
  if (!await leaf.verify({ publicKey: int.publicKey }))  throw err(401, 'leaf not signed by intermediate');
  if (!await int.verify({ publicKey: root.publicKey }))  throw err(401, 'intermediate not signed by Apple');

  // The challenge must appear (as a nonce) inside the leaf cert
  const nonce = await sha256(concat(authData, await sha256(enc(challenge))));
  const ext = leaf.getExtension('1.2.840.113635.100.8.2');
  if (!bytesEqual(nonce, extractNonce(new Uint8Array(ext.value)))) throw err(401, 'nonce mismatch');

  // Pin the device public key
  const spki = new Uint8Array(leaf.publicKey.rawData);
  const k = await crypto.subtle.importKey('spki', spki, { name: 'ECDSA', namedCurve: 'P-256' }, true, ['verify']);
  const publicKeyRaw = new Uint8Array(await crypto.subtle.exportKey('raw', k));
  return { publicKeyRaw };
}

Verifying the assertion

This runs each time the app wants a fresh session token. It’s short—and it’s where I lost the day.

export async function verifyAssertion({ assertion, challenge, env, publicKeyRaw, lastCounter }) {
  const { signature, authenticatorData } = decodeAssertion(b64urlDecode(assertion));
  if (!bytesEqual(authenticatorData.slice(0, 32), await sha256(enc(env.APP_ID)))) throw err(401, 'rpId mismatch');
  const counter = readUInt32BE(authenticatorData, 33);
  if (counter <= lastCounter) throw err(401, 'replay');

  // The signature is ECDSA-with-SHA256 OVER THE NONCE.
  const nonce = await sha256(concat(authenticatorData, await sha256(enc(challenge))));
  const key = await crypto.subtle.importKey('raw', publicKeyRaw, { name: 'ECDSA', namedCurve: 'P-256' }, false, ['verify']);
  const ok = await crypto.subtle.verify({ name: 'ECDSA', hash: 'SHA-256' }, key, derSigToRaw(signature), nonce);
  if (!ok) throw err(401, 'bad assertion signature');
  return counter;
}

The session token itself is just an HMAC-signed { sub: keyId, exp }—verify the signature and expiry, then rate-limit per device and cap the input size before forwarding to the LLM. Nothing exotic; the security is in the layers, not any one clever trick.


The client (iOS)

Pleasantly small, and—surprise—no entitlement required (more on that below):

import DeviceCheck
import CryptoKit

func refreshSession() async throws -> String {
    guard DCAppAttestService.shared.isSupported else { throw Error.unsupported } // false in Simulator!
    let keyId = try await ensureAttestedKey()
    let challenge = try await fetchChallenge()
    let hash = Data(SHA256.hash(data: Data(challenge.utf8)))
    let assertion = try await DCAppAttestService.shared.generateAssertion(keyId, clientDataHash: hash)
    // POST { keyId, assertion, challenge } → { token, expiresAt }, cache the token
}

Cache the token, attach it as Authorization: Bearer …, and on a 401 drop the token so the next call transparently re-attests.


The gotchas (the actual point of this post)

1. The assertion signature is over the double-hashed nonce

This is the one. Attestation worked, the key pinned fine, and /session returned 401 bad assertion signature. I logged every length—authData was 37 bytes, the signature converted to a clean 64-byte r‖s, the public key was a textbook 65-byte uncompressed point. Everything was correct. The signature still wouldn’t verify.

The trap is in how you read Apple’s spec. It says: compute nonce = SHA256(authenticatorData || clientDataHash), then “verify the signature is valid for the nonce.” The intuitive reading is that the nonce is the message digest, so you’d verify against authenticatorData || clientDataHash and let Web Crypto hash it once to reproduce the nonce. That fails. The signature is ECDSA-with-SHA256 over the nonce itself—so Web Crypto needs to hash the nonce again:

// ❌ wrong — what the docs seem to say
verify(key, sig, concat(authData, clientDataHash));
// ✅ right — sign data IS the nonce; verify() hashes it once more
const nonce = await sha256(concat(authData, clientDataHash));
verify(key, sig, nonce);

I found it by brute force: verify both ways in the same request and log which returns true.

const okA = await verify(key, sig, concat(authData, clientDataHash));        // false
const okB = await verify(key, sig, await sha256(concat(authData, cdh)));      // true

okB=true. One sha256 call. One day.

2. @peculiar/x509 needs a polyfill in Workers

First run: tsyringe requires a reflect polyfill. The library uses dependency injection under the hood. The fix is one line, but it has to be the very first import of your worker entry:

import 'reflect-metadata';

3. The Apple root cert: the name is a trap

The file is Apple_App_Attestation_Root_CA.pem“Attestation,” not “Attest.” The obvious ..._App_Attest_Root_CA.pem URL 404s. Also: the root key is P-384 while device leaf keys are P-256, so import the leaf as P-256 and let the library handle the chain’s curves. And never hardcode the root from memory—a wrong root fails closed, silently 401-ing everything. Pull the published PEM and pin that.

4. wrangler tail lies about status

My worker catches errors and returns a Response (even for a 401). From the runtime’s view the handler succeeded—so wrangler tail cheerfully printed Ok for every rejected request. It is not a 200. Add a console.log in your catch during bring-up or you’ll stare at a wall of green while everything fails.

5. Do NOT add the App Attest entitlement

I “helpfully” added com.apple.developer.devicecheck.appattest-environment and immediately broke signing: “provisioning profile doesn’t match the entitlements file.” It turns out App Attest needs no entitlement at all. DCAppAttestService auto-selects the environment—development for dev-signed builds, production for distribution—and if your server accepts both AAGUIDs (appattest and appattestdevelop), it just works. Only add the entitlement if you specifically need to override the environment.

6. You cannot test this in the Simulator

DCAppAttestService.isSupported is false in the Simulator. Real device only. Guard for it and fail with a clear message, or you’ll chase a phantom.

7. Trust your debugging tools, not your assumptions

The meta-lesson behind #1 and #4: when every value looks right and it still fails, stop reasoning and start measuring. Logging the byte lengths proved the encodings were fine, which is what pointed the finger at the construction of the signed data rather than the parsing of it. And validating the worker in wrangler dev --local—actually running it, not just bundling it—is what would have surfaced the reflect-metadata issue before deploy.


Was it worth it?

For an app that calls an LLM on someone else’s dime: yes. The whole thing is maybe 300 lines across client and server, the bundle is ~80 KB, and the property it buys—only my genuine app can spend my tokens—is one you can’t get from any secret you ship in a binary.

Just remember what it is and isn’t. App Attest bounds abuse to “real devices an attacker physically controls”—expensive, not impossible. It’s integrity, not authorization, and not a rate limiter. Layer all three and you’ve got something that actually holds up.

And if you take one thing from this: the signature is over the nonce, and verify() hashes it again.

Back to Articles

Related Posts

View All Posts »
Apple Intelligence - AI for the Rest of Us

Apple Intelligence - AI for the Rest of Us

Discover how Apple Intelligence is making AI personal, private, and offline-capable. Learn about Apple's on-device Foundation Models, Private Cloud Compute, and the Core ML ecosystem.