App attestation (iOS and macOS)
For Apple platform developers adding attestation to an app that already uses TrustPin pinning. At the end of this page, your requests carry a token your backend can verify.
Using an AI coding agent? Install the TrustPin skill and let it wire this up for you.
How it works
TrustPinAttestKit uses Apple’s App Attest to attest a device key with the attestation verifier you run, once per key. The verifier answers with a short-lived signed token, which the framework renews cheaply with the same key. The framework adds the token to requests for the hosts you choose as the TrustPin-Attestation header, and your backend verifies it and decides what an unattested request may do.
The framework never blocks or fails your requests. When no token is available within a short bound, the request is sent without the header.
Requirements
| Item | Requirement |
|---|---|
| Platforms | iOS 15.0+, macOS 13.0+, watchOS 9.0+, tvOS 15.0+, Mac Catalyst 15.0+, visionOS 2.0+ |
TrustPinKit | Set up in your app, at the same version as TrustPinAttestKit |
| Verifier | Deployed, reachable over HTTPS, with a published policy |
| App Attest | The capability on your App ID and the entitlement in the app. Some Macs report App Attest unsupported; see Devices without App Attest |
SDK and verifier compatibility
| SDK | Verifier |
|---|---|
TrustPinAttestKit 7.1.0 | Contract version 2 or newer: verifier 1.0.0 or later |
With an older verifier the state is failed(.verifierIncompatible, …) and no header is sent until the verifier is updated.
Before you write code: the policy
Your verifier checks every attestation against a signed policy, which you manage on the project’s Attestation page, Policy tab, and publish with Sign and publish. These settings matter for an Apple app:
| Setting | What to set | What it does |
|---|---|---|
| Attest Apple apps | Team ID and Bundle ID | An unlisted app is refused with app_not_allowed, in both modes |
| Accept development builds | On while you test builds installed from Xcode | Needed for the development entitlement value |
| Require token binding | On to refuse tokens that are not bound to a session | See Bind tokens to the user’s session |
| Allow debug tokens | Off in production | See Simulators |
| Mode | Monitor (recommended to start), then Enforce | Monitor issues a token even when a policy check fails and records the result in it |
Register your verifier’s domain in your TrustPin project. Calls to the verifier are pinned, and in strict mode an unregistered verifier host is never reached.
Add the App Attest capability to your App ID, and the entitlement to the app:
<key>com.apple.developer.devicecheck.appattest-environment</key>
<string>production</string>The value development, for builds installed from Xcode, needs Accept development builds on.
Install
Swift Package Manager
The swift.sdk package vends these products:
| Product | What it is |
|---|---|
TrustPinKit | The pinning framework (binary) |
TrustPinAttestKit | The attestation framework (binary). Depends on TrustPinKit from the same package. Import it as TrustPinAttestKit; its API lives on the TrustPinAttest type |
TrustPinKitAlamofire | Optional Alamofire adapter. Swift Package Manager only, Alamofire 5.10 or later |
dependencies: [
.package(url: "https://github.com/trustpin-cloud/swift.sdk", from: "7.1.0")
],
targets: [
.target(
name: "YourApp",
dependencies: [
.product(name: "TrustPinKit", package: "swift.sdk"),
.product(name: "TrustPinAttestKit", package: "swift.sdk")
]
)
]CocoaPods
pod 'TrustPinKit', '~> 7.1'
pod 'TrustPinAttestKit', '~> 7.1'TrustPinAttestKit requires TrustPinKit at exactly the same version; CocoaPods resolves it for you.
Configure
Add two keys to the TrustPin-Info.plist that already holds your pinning configuration:
| Key | Type | Required | Notes |
|---|---|---|---|
AttestVerifierURL | String | Yes | HTTPS base URL of your attestation verifier, without a version segment, for example https://api.example.com/attest. A deployment path is allowed. No credentials, query, fragment or IP address |
AttestedHosts | Array of String | Yes | One to fifty hosts whose requests receive the TrustPin-Attestation header. *.example.com covers exactly one label. IP addresses are not accepted |
Unknown keys are ignored, so the pinning and attestation keys share one file. For code-based configuration use try TrustPinAttestConfiguration(verifierURL:attestedHosts:), which throws on an invalid value.
Set up
Set up pinning first, then attestation:
import TrustPinKit
import TrustPinAttestKit
try await TrustPin.setup(.fromPlist())
try await TrustPinAttest.setup(.fromPlist())
TrustPinAttest.bindToHeader("Authorization")
TrustPinAttest.prefetch()setupvalidates the configuration, loads any key attested in an earlier launch and makes no network call.- The first attestation starts from
prefetch(),setTokenBinding(_:),getToken()or the first request to an attested host, whichever comes first. - A second
setupof one instance throwsalreadyInitialized; one made while the first is still running throwsinvalidConfiguration. prefetch()never throws.- Set up the pinning instance with the same identifier first. Without it, verifier calls fail with
verifierUnavailable.
Named instances
If you use named TrustPin instances, attestation pairs with the instance of the same identifier, which must be set up first:
let pinning = try TrustPin.instance(id: "customer-api")
try await pinning.setup(.fromPlist())
let attest = try TrustPinAttest.instance(id: "customer-api")
try await attest.setup(TrustPinAttestConfiguration(verifierURL: url, attestedHosts: ["api.example.com"]))Every static call on TrustPinAttest delegates to the default instance. The URL protocol serves the default instance only.
Bind tokens to the user’s session
Binding ties each token to your user’s session, so a token cannot be replayed with another session. Only a SHA-256 travels: the value never leaves the device.
setTokenBinding(_ value: String?)binds every token to a value you choose, for example your session identifier. Passingnilremoves the fixed binding.bindToHeader(_ name: String?)binds each request’s token to that header’s value on the request, typicallyAuthorization. Passingnilstops header binding.
Each mode clears the other. A change of binding renews the token with the existing key: at once with setTokenBinding, and on the next request carrying the new value with header binding. With header binding, getToken() returns a token bound to the most recent request the instance saw; setTokenBinding(nil) removes the fixed binding only.
prefetch() with bindToHeader attests early but cannot bind, since there is no request yet: the first bound request renews the token once more, cheaply.
With Require token binding on, nothing can be issued until a binding is known, and failed(.bindingRequired, retryAt: .distantFuture) is expected before sign-in. Call setTokenBinding(_:) with the session value before prefetch().
Send the header
A. URLSession.shared with the URL protocol. After TrustPin.setup(.fromPlist(), autoRegisterURLProtocol: true), requests to the configured hosts get the header automatically. This path serves the default instance only.
B. Your own URLSession. A pinning delegate cannot change headers, so ask for the header per request:
let session = URLSession(configuration: .default, delegate: TrustPin.makeURLSessionDelegate(), delegateQueue: nil)
var request = URLRequest(url: url)
request = await TrustPinAttest.addingAttestation(to: request)
let (data, response) = try await session.data(for: request)C. Alamofire. With TrustPinKitAlamofire, pin with TrustPinServerTrustEvaluating, add TrustPinAttestationAdapter() to your session’s interceptor, and pass TrustPinAttestationRedirectHandler() as its redirectHandler. The header is added automatically and requested again on redirects.
D. Anything else (gRPC, WebSockets). Read the token yourself:
let token = try await TrustPinAttest.getToken()
// Send it in the TrustPinAttest.headerName header.These rules apply to every path:
- HTTPS only. The header is only ever added to
httpsrequests. - Attested hosts only.
*.example.comcovers exactly one label, and IP addresses are not accepted. - A header your app sets. The automatic paths never replace an existing header.
addingAttestation(to:)sets it, because your app asked. - Redirects. The header never follows a redirect unchecked: TrustPin’s URL protocol, its session delegates and the Alamofire redirect handler remove it and ask again for the new destination. If your session handles redirects itself without TrustPin’s delegate, call
await TrustPin.redirectedRequest(newRequest)in your redirect callback. - Bounded wait. When no token is available within a short bound, the request is sent without the header. During a wait after a failure it goes out at once without the header.
Sensitive operations
For an operation your backend must not accept on a reused token alone (a payment, a change of beneficiary), ask for an assertion bound to that operation and send both headers:
let digest = Data(SHA256.hash(data: body)) // whatever your backend will verify
let headers = try await TrustPinAttest.assertionHeaders(payloadDigest: digest)
for (name, value) in headers { request.setValue(value, forHTTPHeaderField: name) }The result holds the TrustPin-Attestation token and a TrustPin-Assertion header (TrustPinAttest.assertionHeaderName). Your backend passes both, with its own digest of the operation, to your verifier (see Assertions for sensitive operations). Each call costs one round trip to the verifier besides your request. It throws when no token is available, with the same error getToken() would, and invalidDigest for anything but a 32-byte digest. For endpoints that reuse the token, a short token lifetime and a required binding are the recommended configuration.
Verify the token in your API
Requests to the attested hosts carry TrustPin-Attestation: <token>, a signed JWT issued by your verifier. Your API:
- verifies its signature with the verifier’s published keys, accepting only
ES256andtyptrustpin-attest+jwt; - checks
iss,aud,expandtp.prj; - compares its binding with the session the request authenticates with;
- refuses
tp.env == "debug"in production.
Tokens issued before a switch from monitor to enforce keep fail until they expire. See Verifying tokens in your API for the steps, and Deploying the verifier for the service itself.
sub identifies the device key and changes when the device attests a new key, so do not use it as a stable device identifier.
Simulators
Simulators cannot run App Attest. For development, your verifier’s operator can issue a debug token. The policy (Allow debug tokens) and the verifier (TRUSTPIN_DEBUG_TOKEN_HASHES) must both allow it.
- In the Xcode scheme’s environment, set
TRUSTPIN_DEBUG_TOKENto the token. Never put a debug token in the app bundle or a plist. - If the simulator reports a different app ID from your policy’s, also set
TRUSTPIN_DEBUG_APP_IDtoTEAMID.bundle.id. - Run from Xcode, because scheme variables don’t reach an app opened from the home screen.
The debug path exists only in simulator builds of the framework. Debug tokens carry tp.env debug, which production APIs refuse.
Devices without App Attest
Where the OS reports App Attest unsupported, the state becomes failed(.unsupportedDevice, retryAt: .distantFuture) and requests are sent without the header. Your backend decides whether to serve them.
Apple supports App Attest in apps and in Action, extensible SSO and watchOS extensions. Other extension types are not supported, even where the system reports support, so do not set up attestation in them. Use TrustPinAttestKit from the app process: app extensions that share the app’s Keychain are not supported.
State, errors and logging
TrustPinAttest.state is one of the following. Observe changes with TrustPinAttest.setStateListener { state in … }, which is observe-only, called off the main actor, and does not replay the current state.
| State | Meaning |
|---|---|
unavailable | No token yet: before setup, before the first attestation, or after reset() |
attesting | An attestation or a token renewal is in progress |
ready(expiresAt:) | A token is available until expiresAt; it is renewed before then |
failed(error, retryAt:) | The last attempt failed with a TrustPinAttestError |
Retry time. No attempt is made before retryAt; the next request, getToken() or prefetch() after it tries again. .distantFuture means no automatic retry in this launch, except bindingRequired, which resumes as soon as a binding is set.
All framework errors are cases of TrustPinAttestError:
| Case | Meaning | What to do |
|---|---|---|
invalidConfiguration | A plist key is missing or invalid, or another setup is in progress | Fix the plist; set up once |
alreadyInitialized | setup was called twice on one instance | Set up once per instance |
notSetUp | getToken() before setup | Call setup first |
unsupportedDevice | App Attest is unavailable on this device or simulator, or App Attest keeps rejecting new keys on it | Expected on simulators; use a debug token |
evidenceUnavailable | No evidence this device can produce satisfies the policy | Check the policy’s platform settings |
bindingRequired | The policy requires a binding and none is set | Call setTokenBinding or bindToHeader first |
attestationFailed(reasons:) | The verifier rejected the attestation; reasons carries its codes, or one the framework supplies (http_<status>, invalid_key, invalid_input) | Check that the policy lists your Team ID and Bundle ID and that the entitlement is on the App ID. After a refusal the framework waits before attesting again (SDK 7.0.1 and later), also across launches, until the app is updated; once the cause is fixed, await TrustPinAttest.reset() retries at once |
verifierUnavailable | The verifier could not be reached securely | In strict mode, register the verifier’s domain in your TrustPin project |
verifierIncompatible | The verifier is older than this version of the framework requires (contract version 2 for 7.1.0) | Update the verifier; no token until then |
invalidDigest | assertionHeaders(payloadDigest:) was given something other than a 32-byte SHA-256 digest | Hash the payload with SHA-256 first |
TrustPinAttestState, TrustPinAttestError and TrustPinAttestEvent may gain cases in a minor release: switch over them with @unknown default.
Your verifier decides how long a device waits before trying again; the framework follows its answer, also across launches, and falls back to its own schedule only when no answer arrived.
Logging
Logging is off by default. TrustPinAttest.set(logLevel: .info) enables the framework’s own log (unified logging, subsystem cloud.trustpin.swift.attest). TrustPinAttest.setLogSink(TrustPinAttestClosureLogSink { level, instanceId, message in … }) routes it to your pipeline, and nil restores the default. Whether a request got the header is logged at .debug. Tokens, key certificates, bound values and debug tokens are never logged.
Events
TrustPinAttest.setEventListener { event in … } or for await event in TrustPinAttest.events deliver TrustPinAttestEvent values for your metrics: keys generated or replaced, attestation and renewal outcomes with durations, verifier refusals and waits with their end time, device unsupported, verifier unavailable, assertion outcomes. Payloads carry durations, dates, counts and reason codes only, never a token, a key or a device identifier. The listener is called off the attestation path, so a slow listener delays nothing.
Reset and backups
await TrustPinAttest.reset() discards the attested key and token and returns once the discard is complete; the next request attests a new key, and it ends any wait after a refusal. It is for support flows, not routine use.
Apple asks for one key per user account on each device, so also call it when a different user signs in on the same device. Otherwise leave keys alone, because Apple’s per-device risk metric counts attested keys and cannot be reset.
Keychain items are this-device-only and never synced, so keys and key certificates aren’t backed up. A restored app attests again.
Rolling out
If you have a large user base, enable attestation in stages. Apple may throttle attestation traffic and suggests ramping up no more than 10 million users a day, and staying under 100 attestation requests a second across all installations (Apple’s guidance ). A limit your verifier applies, answered with 429 and Retry-After, slows your apps before they reach Apple.
Start in Monitor (recommended to start), measure the requests without a header and the fail results, then switch to Enforce. See Rolling out.
Troubleshooting
- Backend answers 401, no header on the request: read
TrustPinAttest.stateor enable.debuglogging. Match thefailedcase against the table above. Also check that the host matchesAttestedHostsand that a session with a pinning delegate usesaddingAttestation(to:). attestationFailed(reasons: ["app_not_allowed"]): in either mode, the policy does not list this app, or the verifier is bound to a different TrustPin project than the one whose policy you edited. On simulators, checkTRUSTPIN_DEBUG_APP_ID.attestationFailed(reasons: ["dev_environment"]): a build installed from Xcode, with Accept development builds off. Turn it on while testing.failed(.verifierUnavailable, …): with strict pinning, the verifier’s domain is not registered in your TrustPin project. Also check the verifier URL and its health.failed(.bindingRequired, …)before sign-in: expected with Require token binding on. The first bound request gets the token.- Tokens renew on every request: the binding changes on every request, for example a header value that is not stable within a session.
attestationFailed(reasons: ["too_many_attestations"]): the verifier’s per-key limit (TRUSTPIN_KEY_RATE_LIMIT); the key is kept and the framework waits forretryAt.- Support flow:
failed(.unsupportedDevice, …)after repeated key rejections, or a refusal that persists, is cleared byawait TrustPinAttest.reset()once the cause is fixed. A device that keeps failing after a reset should be reported to your verifier’s operator with the time of the attempt; the verifier logs every attestation and its reasons, which is how a throttled device is told apart from a refused one.
Privacy
TrustPinAttestKit ships its own privacy manifest: no tracking, no collected data, no required-reason APIs. It sends evidence and tokens only to your verifier, and nothing to TrustPin. The device key never leaves the Secure Enclave, and bound values never leave the device. Tokens identify a device key, not a person, and change when the key does. If you forward events to a third-party service, that disclosure is yours to make.