A client calls your premium export endpoint with a valid login token. The request looks ordinary, but it came from a modified copy of your app that bypassed the purchase screen. Authentication tells you which account made the call; it does not tell you much about the app instance that sent it.

App Attest adds a device-bound key that Apple certifies for your app. Your server verifies that attestation once, saves the public key, and later checks assertions over sensitive requests. The private key stays on the device. This is useful evidence, not a universal proof that every line of client code is safe. A compromised device can still act as a broker, and a legitimate app can send a bad request. Keep TLS, authentication, authorization, and server-side business rules.

We’ll protect a POST /v1/exports request in a document app. The sample endpoint names and JSON envelope are our own protocol; the DCAppAttestService methods are Apple’s APIs. Apple’s App Attest overview and server validation guide are the source of truth for the cryptographic checks.

1. Choose what an attestation means for your service

Use App Attest where an altered client could cause meaningful harm: issuing rewards, reading premium content, creating exports, or submitting a game score. Do not ask Apple to attest a new key on every screen load. Attestation involves an Apple service and may fail transiently; assertions are generated on device after the server has accepted the key.

Configure the App Attest capability for the registered App ID and set the appropriate development or production environment. Apple documents the entitlement and environment behavior. Distributed builds, including TestFlight, use production mode. Keep development and production keys in separate server records.

At launch, check DCAppAttestService.shared.isSupported. It is a capability check, not a client-side permission to skip your server’s rules. Apple notes that support depends on device and app type; its WWDC26 App Attest session also calls out that only some extension types can use it. If support is absent, send that state through your normal authenticated flow and let the server apply your documented fallback. An unexpected surge of unsupported responses is itself worth investigating.

The sequence for our export looks like this:

  1. The app generates a key identifier and stores it locally.
  2. The server issues a short-lived, single-use attestation challenge.
  3. The app asks Apple to attest the key using the challenge hash and sends the result to the server.
  4. The server verifies the attestation and stores the public key for that account and device.
  5. Before an export, the server issues another single-use challenge. The app binds that challenge and the exact export body into clientData, generates an assertion, and sends both.
  6. The server verifies the assertion and authorizes the export in one transaction.

The server owns challenge creation and consumption. A challenge supplied by the client would let an attacker replay a previously valid proof.

2. Generate a key and keep its identifier on device

generateKey() returns a key identifier, not a private key. Store the identifier in the Keychain; the private key remains bound to the device. The example uses a device-only Keychain item so an identifier does not migrate to a device where its key cannot work.

import DeviceCheck
import Foundation
import Security

enum AttestKeyStore {
    private static let service = "com.example.Documents.appattest"
    private static let account = "current-user-key-id"

    static func load() throws -> String? {
        let query: [String: Any] = [
            kSecClass as String: kSecClassGenericPassword,
            kSecAttrService as String: service,
            kSecAttrAccount as String: account,
            kSecReturnData as String: true,
            kSecMatchLimit as String: kSecMatchLimitOne
        ]
        var result: CFTypeRef?
        let status = SecItemCopyMatching(query as CFDictionary, &result)
        if status == errSecItemNotFound { return nil }
        guard status == errSecSuccess,
              let data = result as? Data,
              let keyID = String(data: data, encoding: .utf8) else {
            throw AttestError.keychain(status)
        }
        return keyID
    }

    static func save(_ keyID: String) throws {
        let query: [String: Any] = [
            kSecClass as String: kSecClassGenericPassword,
            kSecAttrService as String: service,
            kSecAttrAccount as String: account
        ]
        let value = keyID.data(using: .utf8)!
        let attributes: [String: Any] = [kSecValueData as String: value]
        let updateStatus = SecItemUpdate(
            query as CFDictionary, attributes as CFDictionary
        )
        if updateStatus == errSecSuccess { return }
        guard updateStatus == errSecItemNotFound else {
            throw AttestError.keychain(updateStatus)
        }
        var item = query
        item[kSecValueData as String] = value
        item[kSecAttrAccessible as String] =
            kSecAttrAccessibleAfterFirstUnlockThisDeviceOnly
        let addStatus = SecItemAdd(item as CFDictionary, nil)
        guard addStatus == errSecSuccess else {
            throw AttestError.keychain(addStatus)
        }
    }

    static func replaceInvalidKey(_ oldKeyID: String) async throws {
        // Only replace this account's stored identifier after the device
        // reports that this exact key can no longer be used.
        guard try load() == oldKeyID else { throw AttestError.keyChanged }
        let newKeyID = try await DCAppAttestService.shared.generateKey()
        guard try load() == oldKeyID else { throw AttestError.keyChanged }
        try save(newKeyID)
    }
}

enum AttestError: Error {
    case unsupported
    case keychain(OSStatus)
    case badChallenge
    case server(Int)
    case keyChanged
    case retryAttestation(AttestChallenge)
}

func isInvalidAppAttestKey(_ error: Error) -> Bool {
    let nsError = error as NSError
    return nsError.domain == DCError.errorDomain &&
        nsError.code == DCError.Code.invalidKey.rawValue
}

func isAppAttestServerUnavailable(_ error: Error) -> Bool {
    let nsError = error as NSError
    return nsError.domain == DCError.errorDomain &&
        nsError.code == DCError.Code.serverUnavailable.rawValue
}

func existingOrNewKeyID() async throws -> String {
    let service = DCAppAttestService.shared
    guard service.isSupported else { throw AttestError.unsupported }
    if let existing = try AttestKeyStore.load() { return existing }

    let keyID = try await service.generateKey()
    try AttestKeyStore.save(keyID)
    return keyID
}

This example has one current key for one signed-in user. If the account changes, namespace the Keychain item by a stable account identifier, or deliberately clear and re-enroll. Never silently reuse an attested key across unrelated accounts. Apple recommends one key per user on a device for account-based apps, or one per installation otherwise. Keys survive ordinary app updates, but not reinstall, device migration, or backup restoration. A Keychain identifier can outlive its device key, so replaceInvalidKey changes only the stored identifier for this account after DeviceCheck reports invalidKey. Serialize enrollment and recovery for an account so two requests cannot replace each other’s keys. Your server should accept a legitimate new key for an existing account while retaining other devices’ keys.

3. Attest the key with a server challenge

Here is a compact client transport for the two attestation calls. Assume session is configured with your ordinary authentication and TLS settings. The server’s challenge response contains an opaque id and Base64-encoded random bytes; the app must hash the decoded bytes, not the Base64 text. The endpoints and payload types are illustrative and should match your backend.

import CryptoKit
import DeviceCheck
import Foundation

struct AttestChallenge: Decodable {
    let id: String
    let bytes: String // Base64
}

struct AttestationUpload: Encodable {
    let keyID: String
    let challengeID: String
    let object: String // Base64 CBOR attestation object
}

struct KeyStatus: Decodable {
    let registered: Bool
}

struct AttestAPI {
    let baseURL: URL
    let session: URLSession

    func isRegistered(_ keyID: String) async throws -> Bool {
        var request = URLRequest(
            url: baseURL.appending(path: "v1/app-attest/keys/status")
        )
        request.httpMethod = "POST"
        request.setValue("application/json", forHTTPHeaderField: "Content-Type")
        request.httpBody = try JSONEncoder().encode(["keyID": keyID])
        let (data, response) = try await session.data(for: request)
        try check(response)
        return try JSONDecoder().decode(KeyStatus.self, from: data).registered
    }

    func challenge(for purpose: String) async throws -> AttestChallenge {
        var request = URLRequest(
            url: baseURL.appending(path: "v1/app-attest/challenges")
        )
        request.httpMethod = "POST"
        request.setValue("application/json", forHTTPHeaderField: "Content-Type")
        request.httpBody = try JSONEncoder().encode(["purpose": purpose])
        let (data, response) = try await session.data(for: request)
        try check(response)
        return try JSONDecoder().decode(AttestChallenge.self, from: data)
    }

    func upload(_ payload: AttestationUpload) async throws {
        var request = URLRequest(
            url: baseURL.appending(path: "v1/app-attest/keys")
        )
        request.httpMethod = "POST"
        request.setValue("application/json", forHTTPHeaderField: "Content-Type")
        request.httpBody = try JSONEncoder().encode(payload)
        let (_, response) = try await session.data(for: request)
        try check(response)
    }

    private func check(_ response: URLResponse) throws {
        guard let http = response as? HTTPURLResponse,
              (200..<300).contains(http.statusCode) else {
            throw AttestError.server(
                (response as? HTTPURLResponse)?.statusCode ?? -1
            )
        }
    }
}

func enrollKey(
    using api: AttestAPI,
    retrying previousChallenge: AttestChallenge? = nil,
    mayReplaceInvalidKey: Bool = true
) async throws {
    let keyID = try await existingOrNewKeyID()
    // The authenticated server answers for this account and environment.
    // A previous upload may have succeeded even if its response was lost.
    if try await api.isRegistered(keyID) { return }
    let challenge: AttestChallenge
    if let previousChallenge {
        challenge = previousChallenge
    } else {
        challenge = try await api.challenge(for: "attestation")
    }
    guard let bytes = Data(base64Encoded: challenge.bytes),
          !bytes.isEmpty else { throw AttestError.badChallenge }

    let hash = Data(SHA256.hash(data: bytes))
    let object: Data
    do {
        object = try await DCAppAttestService.shared.attestKey(
            keyID, clientDataHash: hash
        )
    } catch where isInvalidAppAttestKey(error) && mayReplaceInvalidKey {
        // If the server accepted an earlier upload, keep this key. Otherwise
        // an already-attested or unavailable key needs a new enrollment.
        if try await api.isRegistered(keyID) { return }
        try await AttestKeyStore.replaceInvalidKey(keyID)
        try await enrollKey(
            using: api, mayReplaceInvalidKey: false
        ) // Requests a fresh challenge; recovery runs at most once.
        return
    } catch where isAppAttestServerUnavailable(error) {
        throw AttestError.retryAttestation(challenge)
    }
    try await api.upload(.init(
        keyID: keyID,
        challengeID: challenge.id,
        object: object.base64EncodedString()
    ))
}

The server should rate-limit challenge issuance per account and device. If Apple’s attestation service returns serverUnavailable, the sample throws retryAttestation(challenge). The caller should persist that challenge, wait for a server-directed backoff, then call enrollKey(using:retrying:) with it and the same stored key ID; the server must keep that challenge valid for the retry window. This follows Apple’s same-key, same-hash guidance. A temporary network error also must not rotate the key. On invalidKey, the sample checks server registration first because attestKey can report that code for a key already attested when an upload response was lost. If registration is absent, it replaces only this account’s stored ID, generates a new key, and starts enrollment with a fresh server-controlled challenge. Do enrollment outside the critical tap path when possible, then enforce the required result before the first protected operation.

What the server must verify

Do this on the server, never in the app. Apple’s validation steps and test vector guide cover the binary details. The following is server pseudocode, not a drop-in CBOR or X.509 implementation:

challenge = loadUnusedChallenge(account, challengeID, "attestation")
attestation = parseCBORStrictly(objectBytes)
require attestation.fmt == "apple-appattest"
verifyCertificateChain(attestation.x5c, appleAppAttestRoot)

clientDataHash = SHA256(challenge.rawBytes)
nonce = SHA256(attestation.authData || clientDataHash)
require certificateExtension(attestation.leaf, "1.2.840.113635.100.8.2")
        == nonce

publicKey = extractP256PublicKey(attestation.leaf)
require SHA256(x962Uncompressed(publicKey)) == decodeKeyID(keyID)
require attestation.authData.rpID == SHA256(teamID + "." + bundleID)
require attestation.authData.counter == 0
require attestation.authData.aaguid == expectedEnvironmentAAGUID
require attestation.authData.credentialID == decodeKeyID(keyID)
validateKnownAuthenticatorExtensions(attestation.authData)

atomically:
    require challenge unused, unexpired, and owned by account
    require publicKey not bound to a different account
    save(keyID, publicKey, account, environment, counter: 0,
         receipt: independentlyVerifiedReceipt)
    consume(challenge)

The certificate’s nonce extension is ASN.1 encoded; compare its decoded octet string. Validate certificate validity and chain against Apple’s App Attest root, not a generic system trust anchor. Compare the expected App ID using the App ID prefix from your provisioning configuration and bundle identifier. Development and production have different AAGUID values; a production verifier must not accept a development key because its certificate otherwise looks plausible. Extract and verify the receipt independently before storing it for a later fraud-metric lookup.

Do not trust keyID, environment, app version, or user ID merely because they arrived in JSON. They must agree with the verified attestation and authenticated account. Keep one key record per device and account; a user can own several devices.

4. Bind an assertion to the exact export request

An attested key says something about a specific installation at enrollment time. For a later export, make the app sign clientData that covers a fresh challenge and the actual operation. generateAssertion takes a SHA-256 hash of those bytes. The server must receive the same bytes, reconstruct what they mean, and compare them with the request it is about to execute.

The following types make the binding explicit. We sign the HTTP method, fixed path, challenge ID and bytes, and SHA-256 of the UTF-8 JSON body. The app sends clientData as Base64 to preserve its exact encoding. The backend must decode and validate every field against the received request, then hash those original bytes; it must not re-serialize JSON and hope for an identical byte sequence.

import CryptoKit
import DeviceCheck
import Foundation

struct ExportBody: Encodable {
    let documentID: String
    let format: String
}

struct ExportClientData: Encodable {
    let challengeID: String
    let challenge: String // Base64 server nonce
    let method: String
    let path: String
    let bodySHA256: String // Base64
}

struct ProtectedExport: Encodable {
    let keyID: String
    let clientData: String // Base64 of exact signed bytes
    let assertion: String // Base64 CBOR assertion
    let body: String // Base64 of exact export JSON bytes
}

func makeProtectedExport(
    documentID: String,
    api: AttestAPI
) async throws -> ProtectedExport {
    let keyID = try await existingOrNewKeyID()
    let challenge = try await api.challenge(for: "export")
    guard let nonce = Data(base64Encoded: challenge.bytes),
          !nonce.isEmpty else { throw AttestError.badChallenge }

    let encoder = JSONEncoder()
    encoder.outputFormatting = [.sortedKeys]
    let body = try encoder.encode(ExportBody(
        documentID: documentID, format: "pdf"
    ))
    let clientData = try encoder.encode(ExportClientData(
        challengeID: challenge.id,
        challenge: nonce.base64EncodedString(),
        method: "POST",
        path: "/v1/exports",
        bodySHA256: Data(SHA256.hash(data: body)).base64EncodedString()
    ))
    let hash = Data(SHA256.hash(data: clientData))
    let assertion: Data
    do {
        assertion = try await DCAppAttestService.shared.generateAssertion(
            keyID, clientDataHash: hash
        )
    } catch where isInvalidAppAttestKey(error) {
        // The identifier can survive in Keychain after reinstall or restore.
        // Re-enrollment uses a new key and challenge; do not reuse this proof.
        try await AttestKeyStore.replaceInvalidKey(keyID)
        try await enrollKey(using: api)
        throw AttestError.keyChanged // Caller retries export with a new challenge.
    }
    return ProtectedExport(
        keyID: keyID,
        clientData: clientData.base64EncodedString(),
        assertion: assertion.base64EncodedString(),
        body: body.base64EncodedString()
    )
}

Send that envelope to POST /v1/exports using the same authenticated URLSession as the challenge request. The server decodes body and executes that exact body only after verification. In a production API, avoid nested Base64 if your transport supports signing and sending raw body bytes plus separate proof headers; the important property is byte-for-byte agreement. You can also include an account-scoped operation ID to make retries idempotent.

On the server, assertion verification is pseudocode:

record = loadKey(keyID, authenticatedAccount)
challenge = loadUnusedChallenge(account, clientData.challengeID, "export")
require clientData.challenge == base64(challenge.rawBytes)
require clientData.method == "POST"
require clientData.path == "/v1/exports"
require clientData.bodySHA256 == base64(SHA256(receivedBodyBytes))

assertion = parseCBORStrictly(assertionBytes)
clientDataHash = SHA256(receivedClientDataBytes)
digest = SHA256(assertion.authenticatorData || clientDataHash)
require verifyP256Signature(record.publicKey, digest, assertion.signature)
require assertion.authenticatorData.rpID == SHA256(expectedAppID)
require assertion.authenticatorData.counter > record.lastCounter
validateKnownAuthenticatorExtensions(assertion.authenticatorData)

atomically:
    require challenge unused and unexpired
    require assertion.authenticatorData.counter > record.lastCounter
    consume(challenge)
    update(record.lastCounter, assertion.authenticatorData.counter)
    authorizeAndCreateExport(authenticatedAccount, receivedBodyBytes)

The counter comparison and challenge consumption must be atomic. Concurrent requests using one key can arrive out of order: if counter 12 reaches the server before 11, reject 11 and let the client retry with a fresh challenge and assertion. Do not loosen the monotonic check to accommodate network reordering. A captured valid assertion must never authorize a second export. Apple’s server guide specifies the signed digest, RP ID, challenge, and increasing counter checks.

Assertions have local cryptographic cost, even though they avoid a round trip to Apple’s attestation service. Reserve them for sensitive calls or documented checkpoints. If an export runs for minutes after the request is accepted, BGContinuedProcessingTask for user-started work describes how to keep its on-device portion alive; App Attest only covers the authorization of the request.

5. Handle renewal, failure, and risk signals

An invalid key after reinstall is ordinary. A compromised installation can also present a new key. Treat a new attestation for an existing account as an event to evaluate, not immediate proof of fraud. Keep other devices’ keys until the user revokes them or your policy expires them. Rotate a key when it is lost or no longer trusted; do not rotate on each failed network attempt. Since the private key cannot be exported, device migration requires new enrollment.

Separate three cases in your API response. unsupported means this app/device combination cannot use App Attest; apply a defined fallback. retryLater means a temporary Apple or network failure; queue enrollment and limit access until it succeeds. rejected means server verification failed; record the reason internally and require a new safe enrollment path or deny the sensitive operation. Avoid telling an attacker exactly which certificate or signature check failed. The policy can vary by endpoint: a read-only document list may remain available while premium export waits for verification.

Apple’s WWDC26 session describes new iOS 27 authenticator-data extensions for launch validation category and bundle version. If your server parses those extensions, compare them with distribution channels and versions you actually shipped, then add unexpected values to the account’s risk assessment. Do not interpret an absent extension from an older system as a failed attestation, and do not invent new client API properties; these are fields in the signed data your server examines. Apple’s validation guide names the extension keys apple_validation_category_01 and apple_bundle_version_01 for attestation.

The attestation receipt can be sent by your server to Apple’s fraud metric service. The metric is an approximate count of unique keys attested on that device over the previous 30 days, not a judgment about a particular user. Refresh it according to the receipt’s validity window and watch for unusual changes. Reinstalls and legitimate key turnover can raise it; a compromised device can also broker attestations. Apple’s fraud risk documentation covers the server-to-server flow. Use the result alongside account history, payment risk, rate limits, and your actual abuse data.

Testing should include a valid enrollment and assertion, expired or reused challenges, altered export bodies, wrong App ID, wrong environment, duplicate key bound to another account, stale counters, concurrent requests, reinstall, and an unavailable Apple service. Apple’s attestation validation test vector is especially useful for catching ASN.1 and CBOR parser mistakes. Run these as backend tests before relying on the gate in production. Swift Testing for domain and integration checks can cover the client state machine; the certificate and counter rules belong in server tests.

Key Takeaways

  • Enroll a device-bound App Attest key once and store its identifier securely; keep development and production records separate.
  • Verify the certificate chain, challenge nonce, app identity, environment, public key, and receipt on the server before accepting a key.
  • Bind each sensitive request’s exact bytes and a fresh challenge into clientData; verify the signature and increasing counter atomically.
  • Treat unsupported devices, reinstallation, iOS 27 extensions, and fraud metrics as defined risk-policy inputs rather than one-bit verdicts.

Conclusion

App Attest is most valuable when it protects a specific server decision. For our export request, the server can connect a verified app key to an authenticated account, reject a replayed or modified request, and still enforce purchase and document permissions. Build that narrow path first, test its failure modes, then expand it only where the extra signal changes a real security decision.