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-swiftOr 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 setting | Auth mode |
|---|---|
| Unauthenticated users | .appAttestInstall |
| Signed-in users only | .appAttest(issuerTokenProvider:) |
Unauthenticated users
authMode: .appAttestInstallApp 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 anX-AppAttest-Proofheader: 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:. ChanginghttpBodyor the URL of the request it built invalidates the proof, and the request is refused with403 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;
withAuthorizedRequestdoes 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 toauthorizedRequestinstead 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,
/meincluded, 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_failedat the token exchange. When a request sent throughwithAuthorizedRequestis refused with401,auth_requiredorassertion_required, it discards the token and builds and sends the request once more. The next exchange answers the app's currentverify_on.assertion_requiredon 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 fromgatewayAccessToken()by hand gets it whenever the app is on Every request. If you send requests built withauthorizedRequestinstead, a401is yours to handle: calldiscardAccessToken()and build the request again, once. A403 attest_failedon 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. CallingdiscardAccessToken()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,registerortokenwith a429, 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 inretryAfter. A token it already holds keeps working throughout. - ID token retries. On
issuer_token_rejectedit asks your provider for a refreshed token once and retries. Onissuer_claims_missingit first runs your recovery hook, described below, then retries with a refreshed token. - App version.
X-App-Versionis set fromCFBundleShortVersionString, 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: aGatewayErrorCodesuch as.appRateLimited, or.unknownfor 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.