Skip to Content
SDK IntegrationApp attestation (iOS and macOS)

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

ItemRequirement
PlatformsiOS 15.0+, macOS 13.0+, watchOS 9.0+, tvOS 15.0+, Mac Catalyst 15.0+, visionOS 2.0+
TrustPinKitSet up in your app, at the same version as TrustPinAttestKit
VerifierDeployed, reachable over HTTPS, with a published policy
App AttestThe 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

SDKVerifier
TrustPinAttestKit 7.1.0Contract version 2 or newer: verifier 0.2.2 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:

SettingWhat to setWhat it does
Attest Apple appsTeam ID and Bundle IDAn unlisted app is refused with app_not_allowed, in both modes
Accept development buildsOn while you test builds installed from XcodeNeeded for the development entitlement value
Require token bindingOn to refuse tokens that are not bound to a sessionSee Bind tokens to the user’s session
Allow debug tokensOff in productionSee Simulators
ModeMonitor (recommended to start), then EnforceMonitor 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:

ProductWhat it is
TrustPinKitThe pinning framework (binary)
TrustPinAttestKitThe attestation framework (binary). Depends on TrustPinKit from the same package. Import it as TrustPinAttestKit; its API lives on the TrustPinAttest type
TrustPinKitAlamofireOptional 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:

KeyTypeRequiredNotes
AttestVerifierURLStringYesHTTPS 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
AttestedHostsArray of StringYesOne 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()
  • setup validates 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 setup of one instance throws alreadyInitialized; one made while the first is still running throws invalidConfiguration.
  • 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. Passing nil removes the fixed binding.
  • bindToHeader(_ name: String?) binds each request’s token to that header’s value on the request, typically Authorization. Passing nil stops 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 https requests.
  • Attested hosts only. *.example.com covers 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:

  1. verifies its signature with the verifier’s published keys, accepting only ES256 and typ trustpin-attest+jwt;
  2. checks iss, aud, exp and tp.prj;
  3. compares its binding with the session the request authenticates with;
  4. 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.

  1. In the Xcode scheme’s environment, set TRUSTPIN_DEBUG_TOKEN to the token. Never put a debug token in the app bundle or a plist.
  2. If the simulator reports a different app ID from your policy’s, also set TRUSTPIN_DEBUG_APP_ID to TEAMID.bundle.id.
  3. 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.

StateMeaning
unavailableNo token yet: before setup, before the first attestation, or after reset()
attestingAn 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:

CaseMeaningWhat to do
invalidConfigurationA plist key is missing or invalid, or another setup is in progressFix the plist; set up once
alreadyInitializedsetup was called twice on one instanceSet up once per instance
notSetUpgetToken() before setupCall setup first
unsupportedDeviceApp Attest is unavailable on this device or simulator, or App Attest keeps rejecting new keys on itExpected on simulators; use a debug token
evidenceUnavailableNo evidence this device can produce satisfies the policyCheck the policy’s platform settings
bindingRequiredThe policy requires a binding and none is setCall 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
verifierUnavailableThe verifier could not be reached securelyIn strict mode, register the verifier’s domain in your TrustPin project
verifierIncompatibleThe verifier is older than this version of the framework requires (contract version 2 for 7.1.0)Update the verifier; no token until then
invalidDigestassertionHeaders(payloadDigest:) was given something other than a 32-byte SHA-256 digestHash 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.state or enable .debug logging. Match the failed case against the table above. Also check that the host matches AttestedHosts and that a session with a pinning delegate uses addingAttestation(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, check TRUSTPIN_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 for retryAt.
  • Support flow: failed(.unsupportedDevice, …) after repeated key rejections, or a refusal that persists, is cleared by await 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.

Next steps