AppAIGatewayDocs
Integrate your app

iOS with the Swift package

Add the AppAIGateway package, pick an auth mode, and send provider requests from your app with no key on the device.

The AppAIGateway Swift package does the part of the integration that is easy to get wrong: App Attest registration, the token exchange, token refresh, signing each request with App Attest when your app verifies every request, and the headers every request needs. You keep sending provider requests with URLSession or any HTTP client. The package builds and signs the request, and decides when to build it again; your code sends it.

Requirements: iOS 16 or later, a real device for App Attest, and an app in the console of type iOS application with your Team ID and Bundle ID.

Add the package

In Xcode, choose File → Add Package Dependencies and enter:

https://github.com/maxceem/app-ai-gateway-swift

Or in Package.swift:

.package(url: "https://github.com/maxceem/app-ai-gateway-swift", from: "1.6.0")

Add the AppAIGateway product to your target and import AppAIGateway. The package is developed in the app-ai-gateway-swift directory of the main repository, which is where to report issues.

Create the client

import AppAIGateway

let gateway = AppAIGatewayClient(
    appID: "example-app-a1b2c3",
    baseURL: URL(string: "https://api.appaigateway.com")!,
    authMode: .appAttestInstall
)

appID is the app's ID from the console. baseURL is https://api.appaigateway.com for the hosted service.

Self-hosting

Set baseURL to your own gateway's API origin, without /v1 or an app path. The package appends those paths itself.

Keep one client per app for the life of the process; it caches the gateway token and refreshes it in the background.

Choose the auth mode

The mode must match the app's User authentication setting in the console.

Console settingAuth mode
Unauthenticated users.appAttestInstall
Signed-in users only.appAttest(issuerTokenProvider:)

Unauthenticated users

authMode: .appAttestInstall

App Attest alone. The install is the user. No sign-in is involved and no ID token is sent.

Signed-in users

Give the client a closure that returns the user's current ID token from your identity provider. The forceRefresh flag is set when the gateway rejected the previous token, so pass it through to your provider's refresh call.

import FirebaseAuth

authMode: .appAttest(issuerTokenProvider: { forceRefresh in
    guard let user = Auth.auth().currentUser else { throw NotSignedIn() }
    return try await user.getIDToken(forcingRefresh: forceRefresh)
})

Return the JWT whose issuer and audience match your gateway configuration: Firebase's ID token, Supabase's session access token, Clerk's configured JWT, or your own. With the Auth0 preset, return an access token issued for your API identifier, as described in Auth0's token guide. The closure is named issuerTokenProvider regardless of the token type.

Send a request

withAuthorizedRequest builds a URLRequest with the URL, the body, the Authorization header, X-App-Version and the request's App Attest proof set, and hands it to your closure to send. Pass the body to it, and send the request your closure receives.

let (data, response) = try await gateway.withAuthorizedRequest(
    provider: .openai,
    providerPath: "v1/responses",
    body: try JSONEncoder().encode([
        "model": "gpt-5.6-sol",
        "input": "Summarise today's meals in one sentence."
    ]),
    contentType: "application/json"
) { try await URLSession.shared.data(for: $0) }

if let error = GatewayError(response: response as! HTTPURLResponse, body: data) {
    throw error
}

The package never performs the request itself, so any session, configuration or networking library works in the closure. If the gateway answers 401, the client discards its token, builds the request again with a fresh token and, when your app verifies every request, a fresh proof, and calls your closure once more. That recovers from an expired gateway token or one that names no install (401 auth_required), and from an app switched to Every request after the token was cached (401 assertion_required). Any 401 is retried without its body being read, so one a provider returned through the proxy, such as for a rejected provider key, costs one extra exchange and one repeated provider call. An error your closure throws, and every other status, 403 attest_failed and 429 included, come back to you untouched.

provider is a provider slug. The constants .openai, .anthropic, .xai, .gemini and .perplexity spell the default slug of each type; use .custom("openai-dev") for any other. providerPath is the provider's own API path with no leading slash.

Headers the provider needs, such as anthropic-version for Anthropic, can be added to the request inside the closure; the proof does not cover them. See Calling the provider proxy for the paths and headers of each provider.

To stream, send with URLSession.bytes(for:). It returns once the response headers arrive, before the body, so the status is known while the stream is still unread. Read the provider's stream as documented by the provider:

let (bytes, response) = try await gateway.withAuthorizedRequest(
    provider: .anthropic,
    providerPath: "v1/messages",
    body: Data(#"{"model":"claude-sonnet-5","max_tokens":512,"stream":true,"messages":[{"role":"user","content":"Plan tomorrow's meals."}]}"#.utf8),
    contentType: "application/json"
) { request in
    var request = request
    request.setValue("2023-06-01", forHTTPHeaderField: "anthropic-version")
    return try await URLSession.shared.bytes(for: request)
}

let http = response as! HTTPURLResponse
guard (200..<300).contains(http.statusCode) else {
    let body = try await bytes.reduce(into: Data()) { $0.append($1) }
    throw GatewayError(response: http, body: body)!
}
for try await line in bytes.lines {
    print(line)
}

A custom endpoint is the same call with a slug:

let (data, response) = try await gateway.withAuthorizedRequest(
    endpointSlug: "chat",
    body: Data(#"{"input":"Suggest a 500 kcal lunch."}"#.utf8),
    contentType: "application/json"
) { try await URLSession.shared.data(for: $0) }

authorizedRequest takes the same arguments without the closure and returns the request alone, for an app that wants to drive the retry itself; see Recovery under What the client does for you.

Show the user where they stand

withCurrentUserRequest sends a GET /v1/apps/{app}/me, which answers the limits that apply to the caller, its requests today, the month's spend against its budget, and whether it is blocked. On an iOS app the request counts are the install's and install_id names it. It retries a 401 the same way; currentUserRequest() builds the request alone.

let (data, _) = try await gateway.withCurrentUserRequest { try await URLSession.shared.data(for: $0) }

Build it with the client rather than from a URL of your own: when your app verifies App Attest on every request, it must be signed like any other gateway request, so one without a proof is refused with 401 assertion_required, and changing a signed request's URL afterwards breaks its proof. The response is described under Calling the provider proxy.

Per-request proof

Each iOS app's Verify App Attest on setting, verify_on, decides whether requests are signed:

  • Every request (every_request, the default for new apps): every request the app sends through the gateway is signed. Alongside the gateway token it carries an X-AppAttest-Proof header: an assertion the install's App Attest key made over that one request. A gateway token copied off the network is worthless without the device's key, and a proof is accepted once. Needs the package 1.4 or later.
  • Token exchange (token_exchange): the assertion is checked once per hourly token, and requests carry only the token.

The token exchange answers with the app's verify_on, and the package signs requests only when it is every_request; a gateway that does not send it is treated as token_exchange. Build every request through the package either way, and switching the setting needs no change in your app.

Apps created before this setting was added were set to Token exchange, so builds already in your users' hands, on a package before 1.4, keep working. Switch to Every request on the app's Auth policy page once every build in use is on 1.4 or later: from then on a request without a proof is refused with 401 assertion_required. A 1.4 client still holding a token from before the switch is refused that way too, until it discards the token and rebuilds the request, which withAuthorizedRequest does on its own; see Recovery under What the client does for you.

With Every request, the client data the key signs is the SHA-256 of these lines joined by a newline, with no trailing newline:

appaigateway-request-v1
<app id>
<HTTP method, upper case>
<URL path and query exactly as sent>
<base64url SHA-256 of the body; an absent body hashes as empty>
<base64url SHA-256 of the gateway token>
<unix seconds>
<16 random bytes, base64url>

and the header is v1.<unix seconds>.<random value>.<assertion, base64url>. The timestamp is signed, but the gateway does not compare it with its own clock; the assertion counter is what stops a proof being used twice.

The package computes all of it. What that asks of your code:

  • Set the body before it is signed. Pass it as body:. Changing httpBody or the URL of the request it built invalidates the proof, and the request is refused with 403 attest_failed. Other headers may be added.
  • Keep provider paths plain ASCII. The proof covers the path and query as the gateway reads them, which is how a standard (WHATWG) URL parser reads the URL that arrives. The package rewrites the URL it returns into that form before signing it: . and .. segments resolved, and spaces, quotes, ' in a query, ^ in a path and any non-ASCII character percent-encoded, with escapes already present left as they are. A plain ASCII path never needs rewriting, so the URL you passed in is the one sent.
  • One request per proof. Build a new request for every send, retries included; withAuthorizedRequest does so for the one it makes. Replaying a request the gateway has already accepted is refused as a replayed assertion.
  • Version 1.4 or later. Earlier versions of the package send no proof, so an app that verifies every request refuses their requests with 401 assertion_required. Moving to 1.4 means passing the body to authorizedRequest instead of assigning it afterwards; nothing else in your code changes.

An app whose API-key mode builds server requests sends no proof: API keys prove themselves.

What the client does for you

  • App Attest registration. On first use it generates a key in the Secure Enclave, asks the gateway for a challenge, attests the key with Apple, and registers it. The key ID is stored in the Keychain under the service dev.aigateway.credentials, so later launches skip this step.
  • Token exchange. It obtains a gateway token valid for one hour by signing a fresh challenge with the key, with the ID token alongside when the mode has one. Concurrent callers share one exchange.
  • Refresh. It refreshes the token five minutes before expiry in a background task, so a request rarely waits for an exchange.
  • Request signing. When the app verifies App Attest on every request, it signs every request it builds, /me included, with the key the gateway token is bound to; see Per-request proof.
  • Recovery. After a reinstall the Keychain may still name a key that no longer exists in the Secure Enclave. The client detects the local signing failure, registers a new key, exchanges it for a token bound to the new key and signs once more, whether that happens at the token exchange or while signing a request. It also replaces the key once when the gateway answers attest_failed at the token exchange. When a request sent through withAuthorizedRequest is refused with 401, auth_required or assertion_required, it discards the token and builds and sends the request once more. The next exchange answers the app's current verify_on. assertion_required on a request built by the package and sent with its headers intact means the app was switched to Every request after its cached token was issued under Token exchange; the rebuilt request is signed. A request assembled from gatewayAccessToken() by hand gets it whenever the app is on Every request. If you send requests built with authorizedRequest instead, a 401 is yours to handle: call discardAccessToken() and build the request again, once. A 403 attest_failed on a request is returned to you, never retried: a key the gateway does not know, an environment it no longer accepts, a malformed proof and a request changed after signing all answer it. Calling discardAccessToken() and building the request again helps when the key was lost, because the fresh exchange re-registers it, and not when the environment is no longer accepted or the request was changed after signing.
  • Rate limits. If the gateway refuses challenge, register or token with a 429, the client remembers how long the wait is and refuses the next call itself until it passes, rather than asking Apple for a new key on every attempt. The error it raises carries the time still left in retryAfter. A token it already holds keeps working throughout.
  • ID token retries. On issuer_token_rejected it asks your provider for a refreshed token once and retries. On issuer_claims_missing it first runs your recovery hook, described below, then retries with a refreshed token.
  • App version. X-App-Version is set from CFBundleShortVersionString, which is what the console's By app version breakdown groups by.

Handling a missing entitlement

With a subscription check, a user who has just paid may hold a token that does not carry the entitlement yet. The gateway answers issuer_claims_missing, and the client runs the issuerRejectionRecovery closure before retrying. Use it to sync the purchase, for example by asking RevenueCat to refresh, so the refreshed token carries the claim:

let gateway = AppAIGatewayClient(
    appID: "example-app-a1b2c3",
    baseURL: URL(string: "https://api.appaigateway.com")!,
    authMode: .appAttest(issuerTokenProvider: { forceRefresh in
        try await Auth.auth().currentUser!.getIDToken(forcingRefresh: forceRefresh)
    }),
    issuerRejectionRecovery: {
        _ = try await Purchases.shared.syncPurchases()
    }
)

The hook runs only for issuer_claims_missing, never for issuer_token_rejected, so an expired token does not trigger a store round trip.

Reading errors

Every refusal is a JSON body with a code, and GatewayError(response:body:) reads it. The useful properties:

  • code: a GatewayErrorCode such as .appRateLimited, or .unknown for a code this version of the package does not know.
  • retryAfter: seconds until a rate limit or budget window reopens.
  • limitScope: "install", "user" or "app" for an app limit, so you can tell "slow this caller down" from "the whole app is busy". An iOS app's request limits count per install, one copy of the app on one device.
  • isRetryable: whether the same request could succeed later without a change.

.rateLimited is the gateway protecting its own authentication endpoints, so it carries no limitScope — it is not one of the limits you set on your app. The client has already started waiting that window out, so show "try again in retryAfter seconds" and leave the user signed in.

The full code list and what each one means for your app is on Errors and limits.

Server-side use of the package

The package also builds requests for server applications, with .apiKey(key:) for an app with no user identity or .apiKey(key:issuerTokenProvider:) for one with signed-in users, and endUserId for an app whose backend names the user. That is useful for a macOS or server-side Swift process. Never use .apiKey inside an iOS app: an API key shipped in an app can be extracted.

On this page