App Attest, or how you can trust an app that cannot keep a secret

27 October 2026

If your app talks to a service with an API key that is compiled into the app, that key is not a secret. Anyone who has the app has the binary, and anyone who has the binary can read what is in it. Reading compiled code has never been easier than it is today, and the AI assistants that now help with it have made it faster still. OWASP lists keys and other sensitive data hardcoded in the app package as a weakness in its own right, and obfuscation does not change that.

So the secrets go on your server, and your server makes the calls that need them. That moves the problem to the server, which now has to decide who is asking. Your app is one caller. A script that copied your app's requests is another, and so is a modified copy of your app. Apple puts the core of the problem plainly in its App Attest documentation: you can't rely on your app's logic to perform security checks on itself, because a compromised app can falsify the results.

That is the job App Attest does. It lets your server check, with a key that the app itself can never read, that a request comes from your app, signed by your team, running on genuine Apple hardware. It is less work than most people expect.

What App Attest tells your server

Apple's WWDC26 session Secure your apps with App Attest is the best explanation Apple has of all this, and it is twenty minutes well spent. In short, an attestation tells your server that:

After that, the app signs individual requests with the same key, and your server checks each signature.

There are two things it does not do. It does not identify a user; that is still the job of your sign-in process. And it cannot make a device with a modified operating system honest. Apple says so in its fraud assessment article: an attacker who modifies the operating system might get around it. That is what the fraud metric, further down, is for.

Important: App Attest is not jailbreak detection. A jailbroken device runs a modified operating system, and that operating system may be modified so that attestations and assertions still pass. Apple's documentation calls this a compromised device; the WWDC26 session says a compromised device could still pass attestations. An Apple engineer put it more directly in a forum reply: App Attest gives general guarantees that your app is running unmodified, but "in the cases of a jailbreak / root, the latter may not always hold true". Do not trust a jailbroken device, whatever its attestations say. And do not rely on jailbreak detection in the app instead: Apple DTS advises against it, because such checks depend on low-level details that break as the system changes.

Why a compromised device can't be trusted is easy to see once you look at what a check is. Every check your app makes is code running on the device: if the answer is good, carry on, otherwise refuse. Whoever controls the operating system controls that code too. Flipping one comparison so that a failing check passes, making a system call return the answer your app was hoping for, loading their own code into your app's process: on a device whose operating system is theirs, all of that is routine work. The same goes for the frameworks your app calls, the one that talks to App Attest included. The WWDC26 session walks through that very case on a Mac with System Integrity Protection turned off, where the attacker modifies the App Attest framework itself. On the Mac, the key's access policy still gives it away, which is why the section on the Mac below says to check it. The lesson holds everywhere: checks belong on your server, where nobody but you can edit them, and even your server can only weigh what such a device sends against every other signal it has.

OWASP, which I quoted at the top, does want apps to look for jailbreaks. Its MASVS-RESILIENCE controls and the weakness it calls Root/Jailbreak Detection Not Implemented cover exactly that, and there are commercial products that do it for you. The rest of what OWASP says lands where this article does. These are defence-in-depth measures for apps with something worth protecting, and leaving them out is not a vulnerability in itself. They must never replace other security controls, none of them is fully effective, and the checks that are publicly documented are easy to get around. OWASP's own advice is to back them with device attestation verified on the server, so the server can still act when the checks in the app have been defeated. If your risk calls for them, add them, or buy them, as one more signal for your server. Keep the decision on the server, and expect them to need updating when the system changes.

How it works

There are three steps, and only one of them talks to Apple.

  1. The app generates a key. The private half stays in the Secure Enclave, and the app gets back an identifier, which it keeps.
  2. The app attests the key once. It asks your server for a challenge, and the system contacts Apple, which returns an attestation object. The app passes that to your server, which checks it and stores the public key.
  3. From then on, for each request that matters, the app asks your server for a challenge and signs the request together with it. That is an assertion. It happens on the device and does not involve Apple at all.

The app side

Note: The code in this article was tested when it was written, in October 2026: the app side compiles against the iOS 27 SDK, and the server checks were run against Apple's validation guide. App Attest, Apple's guidance and the SDKs all change. Before you rely on any of it, check it against the current documentation and test it again.

Here is all of it, in one actor. The protocols at the top stand in for your own networking and keychain code.

import CryptoKit
import DeviceCheck
import Foundation

/// The calls your app makes to your own server. Yours to implement.
protocol AttestServer: Sendable {
    func challenge() async throws -> Data
    func register(keyID: String, attestation: Data, challenge: Data) async throws
    func send(_ request: Data, assertion: Data) async throws -> Data
}

/// Where the key identifier lives. Use a keychain item with
/// kSecAttrAccessibleAfterFirstUnlockThisDeviceOnly, so it never syncs.
protocol KeyIDStore: Sendable {
    func load() -> String?
    func save(_ keyID: String)
    func remove()
}

/// App Attest on the app side: one key per user per device, attested once.
actor AppAttest {
    private let service = DCAppAttestService.shared
    private let server: any AttestServer
    private let store: any KeyIDStore

    init(server: any AttestServer, store: any KeyIDStore) {
        self.server = server
        self.store = store
    }

    /// Run when your server says this install should attest, away from any
    /// user flow. Returns false if the device can't attest; the app carries
    /// on, and your server decides what that device may do.
    func attestIfNeeded() async throws -> Bool {
        guard service.isSupported else { return false }
        let keyID: String
        if let saved = store.load() {
            keyID = saved
        } else {
            keyID = try await service.generateKey()
            store.save(keyID)
        }
        let challenge = try await server.challenge()
        let hash = Data(SHA256.hash(data: challenge))
        do {
            let attestation = try await service.attestKey(keyID, clientDataHash: hash)
            try await server.register(keyID: keyID, attestation: attestation,
                                      challenge: challenge)
            return true
        } catch let error as DCError where error.code == .serverUnavailable {
            // Keep the key. Try again later, with back-off, using the same key.
            throw error
        } catch let error as DCError {
            // Any other DeviceCheck error: this key is no good. Forget it, and
            // the next attempt starts with a new one.
            store.remove()
            throw error
        }
    }

    /// Signs one request. The server checks the signature and the counter.
    func send(_ action: [String: String]) async throws -> Data {
        guard let keyID = store.load() else { throw DCError(.invalidKey) }
        var request = action
        request["challenge"] = try await server.challenge().base64EncodedString()
        let clientData = try JSONEncoder().encode(request)
        let hash = Data(SHA256.hash(data: clientData))
        let assertion = try await service.generateAssertion(keyID, clientDataHash: hash)
        return try await server.send(clientData, assertion: assertion)
    }
}

A few rules come with it, and most of them are in Apple's integration guide or the WWDC26 session.

Generate one key per user account per device, or one for the whole app if it has no accounts, and never share a key between users; Apple's guide explains that a shared key makes it hard to spot one compromised device serving many users. Store the identifier in the keychain with a ThisDeviceOnly accessibility class such as kSecAttrAccessibleAfterFirstUnlockThisDeviceOnly, because, as the guide puts it, there is no way to get the identifier back later, and the key is useless on any other device anyway.

Attest once per key, and let your server decide when. Do it away from any user flow, in the background if you can; the WWDC26 session asks for both. It is easy to end up calling attestKey from somewhere like a view controller's viewDidLoad, especially from inside an SDK, and then every launch of every copy of the app goes to Apple's servers. Keep track of whether the key was attested, and don't ask again.

If attestKey fails with serverUnavailable, keep the key and try again later, with the same key, backing off exponentially between attempts. For any other DeviceCheck error, throw the identifier away and start over with a new key next time. Both rules are in the integration guide, and the back-off is from the WWDC26 session.

Check isSupported before anything else, and when it is false, carry on without App Attest and let the server decide what that device is allowed to do. The WWDC26 session also suggests watching for a rise in unsupported responses from one user, since that can be a sign of tampering.

Use assertions where they count: signing in, payments, premium content, anything you would not want a script doing. Apple's guide reserves them for sensitive moments, and the WWDC26 session points out that generating one costs CPU. Add the round trip to your server for the challenge, and putting one on every API call is a bad trade.

The server side

The server does the real work, and it should, because the app cannot be trusted to check itself. Apple's article Validating apps that connect to your server lists every check, and its Attestation Object Validation Guide gives a real attestation object with the expected value at every step. Run your implementation against that guide before you trust it.

For the attestation, the server:

  1. verifies the certificate chain in the attestation statement up to Apple's App Attest root certificate, which Apple publishes on its certificate authority page;
  2. computes the nonce as the SHA-256 of the authenticator data followed by the client data hash, and checks it against the leaf certificate's extension 1.2.840.113635.100.8.2;
  3. checks that the hash of the public key in the leaf certificate is the key identifier the app sent;
  4. checks the authenticator data: the hash of your App ID, a counter of 0, the environment in aaguid, and the key identifier in credentialId;
  5. on iOS 27 and later, reads the launch validation category and bundle version from the authenticator data's extensions;
  6. stores the public key and the receipt, and associates them with the user and the device.

The attestation follows the WebAuthn authenticator data format, which is where the field layout below comes from. The certificate chain and the CBOR decoding need an X.509 and a CBOR library; use a maintained one for your server's language. The rest is plain hashing and byte comparison. Here are the authenticator data checks, and the assertion check, in Swift with CryptoKit. I ran the attestation check against Apple's validation guide, and it produces the guide's expected nonce.

import CryptoKit
import Foundation

/// Reads bytes front to back, one field at a time, so nothing depends on
/// where a field sits. Asking for more bytes than are left returns nil.
struct ByteReader {
    private var bytes: ArraySlice<UInt8>

    init(_ data: Data) {
        bytes = ArraySlice(data)
    }

    mutating func take(_ count: Int) -> Data? {
        guard count >= 0, bytes.count >= count else { return nil }
        defer { bytes = bytes.dropFirst(count) }
        return Data(bytes.prefix(count))
    }

    /// An unsigned big-endian integer of `count` bytes.
    mutating func uint(_ count: Int) -> UInt64? {
        take(count)?.reduce(0) { $0 << 8 | UInt64($1) }
    }
}

/// WebAuthn authenticator data, as App Attest uses it.
struct AuthenticatorData {
    /// Field sizes, as the WebAuthn specification defines them.
    enum Size {
        static let rpIDHash = 32
        static let flags = 1
        static let counter = 4
        static let aaguid = 16
        static let credentialIDLength = 2
    }

    /// Flag bits from the same section of the specification.
    enum Flag {
        static let attestedCredentialData: UInt8 = 0x40
        static let extensionData: UInt8 = 0x80
    }

    let rpIDHash: Data
    let flags: UInt8
    let counter: UInt32
    let aaguid: Data?         // present when the flags say so: attestations
    let credentialID: Data?

    init?(_ data: Data) {
        var reader = ByteReader(data)
        guard let rpIDHash = reader.take(Size.rpIDHash),
              let flags = reader.take(Size.flags)?.first,
              let counter = reader.uint(Size.counter)
        else { return nil }
        self.rpIDHash = rpIDHash
        self.flags = flags
        self.counter = UInt32(counter)
        // Read the attested credential data only if the flags say it is
        // there; never assume it.
        if flags & Flag.attestedCredentialData != 0 {
            guard let aaguid = reader.take(Size.aaguid),
                  let length = reader.uint(Size.credentialIDLength),
                  let credentialID = reader.take(Int(length))
            else { return nil }
            self.aaguid = aaguid
            self.credentialID = credentialID
        } else {
            aaguid = nil
            credentialID = nil
        }
        // What follows is CBOR: the COSE public key, then the extensions.
        // Apple's own sample object carries extensions without setting
        // Flag.extensionData, so don't use that flag to decide whether to
        // look for them. Decode whatever follows the key, with a CBOR library.
    }
}

enum Environment {
    case development, production

    /// The aaguid values this environment accepts.
    var aaguids: Set<Data> {
        switch self {
        case .production:
            return [Data("appattest".utf8) + Data(repeating: 0, count: 7)]
        case .development:
            // Apple's pages give two different development values; accept both
            // here, and log which one your devices actually send.
            return [Data("appattestdevelop".utf8), Data("appattestsandbox".utf8)]
        }
    }
}

/// The attestation checks that don't need an X.509 or CBOR library. The
/// certificate chain, and the nonce in the leaf certificate's extension
/// 1.2.840.113635.100.8.2, need both; use a maintained library for those.
///
/// `clientDataHash` must be exactly the bytes the app passed to
/// `attestKey`. If the app hashed the challenge, hash it here the same way.
func checkAttestation(authData: Data, keyID: String, clientDataHash: Data,
                      appID: String, environment: Environment) -> Data? {
    guard let auth = AuthenticatorData(authData),
          let aaguid = auth.aaguid,
          let credentialID = auth.credentialID,
          let keyIDBytes = Data(base64Encoded: keyID) else { return nil }
    guard auth.rpIDHash == Data(SHA256.hash(data: Data(appID.utf8))),
          auth.counter == 0,
          environment.aaguids.contains(aaguid),
          credentialID == keyIDBytes else { return nil }
    // The value the leaf certificate's nonce extension must equal.
    return Data(SHA256.hash(data: authData + clientDataHash))
}

/// Checks one assertion against the public key stored at attestation time.
/// Returns the new counter to store, or nil to reject the request.
func checkAssertion(signature: Data, authData: Data, clientData: Data,
                    publicKey: P256.Signing.PublicKey, appID: String,
                    lastCounter: UInt32) -> UInt32? {
    guard let auth = AuthenticatorData(authData),
          auth.rpIDHash == Data(SHA256.hash(data: Data(appID.utf8))),
          auth.counter > lastCounter,
          let ecdsa = try? P256.Signing.ECDSASignature(derRepresentation: signature)
    else { return nil }
    let clientDataHash = Data(SHA256.hash(data: clientData))
    let nonce = Data(SHA256.hash(data: authData + clientDataHash))
    guard publicKey.isValidSignature(ecdsa, for: nonce) else { return nil }
    return auth.counter
}

For an assertion, the server checks the signature over the nonce with the stored public key, the App ID hash, that the challenge in the client data is the one it handed out, and that the counter is higher than the last one it saw for that key. Then it stores the new counter. These are the steps in Apple's validation article. A counter that stays the same or goes backwards means a replay, or, as the WWDC26 session puts it, a copy of the app that does not know where the real one is up to.

Libraries in other languages

If your server is not written in Swift, there are open-source libraries that do all of this, certificate chain and CBOR included. I have not tested any of them. Read the code, check it against Apple's validation guide the same way, and look at how recently it was maintained before you trust it with anything that matters.

If you would rather not run the verification yourself, Firebase App Check can use App Attest as its provider and do it for you.

The things that trip people up

The nonce is built from exactly what the app passed

The nonce is the SHA-256 of the authenticator data followed by whatever bytes the app gave attestKey as clientDataHash. Apple's steps have the app pass the SHA-256 of the challenge, and the server has to hash it the same way. The sample object in the validation guide was made by passing the challenge itself, so if you test against the guide, append the challenge as it is. Either works, as long as both sides agree.

Extensions without the extension flag

In WebAuthn, a flag in the authenticator data says whether extensions follow. The sample object in Apple's validation guide sets only the attested credential data flag (0x40), yet it carries the iOS 27 extensions, apple_validation_category_01 and apple_bundle_version_01. A server that relies on the extension flag will miss them. Decode whatever follows the public key instead.

Two names for the development environment

The validation article says a key attested in development carries appattestdevelop in aaguid, and the article on preparing to use the service says appattestsandbox. Accept both when your server runs in development, log which one your devices send, and never accept either in production, where the value is appattest followed by seven zero bytes.

Development and production do not mix

Keys and receipts from one environment are useless in the other. During development the app uses the sandbox unless you set the App Attest environment entitlement to production. Once the app is on TestFlight or the App Store it always uses production. Test in the sandbox, because, as Apple's onboarding guidance explains, production keeps a count of keys per device that you cannot reset.

Keys do not survive reinstalling

A key lasts through app updates, but not through a reinstall, a restore from a backup (iCloud included), or a move to a new device. The integration guide and the WWDC26 session both say so. So one user will collect several keys over time, one per device and one per reinstall. Accept new keys for an existing user, and do not throw away the old ones straight away.

Where it runs

The WWDC26 session and the integration guide list Action and SSO extensions as supported, along with watchOS, and macOS from version 27. Check isSupported at run time and believe what it says on the device in front of you. An App Clip attests with the full app's App ID, and should store its key identifier in a container the full app can read, so the full app uses the same key.

The Mac

The reference page for isSupported was written before the Mac support arrived. It says isSupported is false on a Mac, for Mac Catalyst apps and for iPhone and iPad apps running on Apple silicon too. That was true until macOS 27. From macOS 27, the WWDC26 session says App Attest works on the Mac, with a condition: every key the Mac generates is bound to a policy that requires full security mode and System Integrity Protection. Apple has not said whether Mac Catalyst apps and iPhone apps on a Mac are included, so ask isSupported there rather than assume.

A Mac with System Integrity Protection turned off can still produce an attestation, so don't take a successful attestation as proof that the Mac is in full security mode. The proof is the key's access policy, which the leaf certificate carries under OID 1.2.840.113635.100.8.6. The validation article gives the one value to accept, and says to trust a Mac's attested key only for exactly that value. One more Mac difference comes from the same article: the RP ID hash is computed over the app's designated code signing requirement instead of its bundle identifier, as TN3127 describes, so check your server computes the right one for Mac clients.

Turning it on for an app with millions of users

Attestation goes through Apple's servers, and they throttle an app that sends too much at once. Apple's onboarding guidance is to ramp up gradually and uniformly, no more than 10 million users a day per app, and to stay under 100 attestation requests a second across all installations. Ship the code with attestation switched off, and have your server turn it on for a growing share of your users. After the first rollout, only new users, new devices and reinstalls attest, and for most apps that traffic does not cause trouble.

Most is not all. If your app goes viral overnight, or a feature launch sends a few million new users your way in a day, that surge is a rollout too, whether you planned it or not. Because your server decides when each copy of the app attests, as the WWDC26 session asks, it can let the new users in and pace their attestations to stay under the limit.

The surge you are most likely to cause yourself is a server failure. Say your server loses its record of which keys it has seen, through an outage, a botched migration or a restore from an old backup. The tempting fix is to tell every copy of the app to attest again. That is your whole user base calling Apple at once, on keys that were already attested, and it is exactly the traffic the onboarding guidance tells you to avoid. Keep the attestation records as safe as anything else you can't rebuild, and if you ever do need to collect attestations again, ramp up the way you did the first time.

Assertions from several requests at once

The counter goes up with every assertion. If the app signs two requests at the same time and they reach the server in the other order, the server sees the counter go backwards, and rejects a perfectly good request. There are two ways out, both suggested in The Swift Dev's walk-through: serialize the signed requests in the app, so that one assertion is made and sent before the next one starts, or have the app retry a rejected request with a fresh challenge, which gets a new assertion with a higher counter.

Never lock people out because attestation failed

Do not make your app depend on attestation succeeding. Networks fail. Apple's service can be slow or unreachable for a while, as any service on the internet can, which is what the serverUnavailable error is for. Keys get rotated by every reinstall and restore, and some devices cannot attest at all. If your app refuses to work whenever an attestation fails or isn't there, then the day attestation has a bad hour is the day your app stops working for everyone, honest users first. That is an outage you built yourself, and it is no use blaming Apple for it afterwards.

A failed or missing attestation is a reason to look closer, never a reason on its own to lock someone out. Apple's advice in the WWDC26 session is to degrade gracefully: limit what that user can do, watch them more closely, and leave the decision to your risk assessment. Keep everything that doesn't need App Attest working without it.

The fraud metric

A device with a compromised operating system can still produce valid attestations, and an attacker can use one such device to attest on behalf of modified copies of your app running on other devices. The fraud metric is how your server notices.

To get the metric, your server sends the receipt that came with an attestation to Apple's App Attest data server. The request is authenticated with a JWT made the same way as for APNs. Apple's server answers with a new receipt, and that new receipt holds the metric: an approximate count of the keys attested for your app on that device in the last 30 days. Store the new receipt in place of the old one, because the next request uses the latest receipt.

Expect the count to be small. Reinstalls and restores push the count up a little; a device that has attested hundreds of keys is almost certainly attesting for somebody else.

The count goes stale, so your server has to ask again from time to time. Each receipt has two dates in it: the earliest time your server may ask for a new count, and the time after which Apple's server may refuse to. A request made before the first date gets a 304, and a receipt left past the second date may stop working, so schedule the requests between the two.

As with every other signal in this article, treat a high count as a reason to look closer, and decide what to do about a suspicious device the same way you decide about any other fraud signal.

Further reading

The WWDC21 session Mitigate fraud with App Attest and DeviceCheck is older, but it covers DeviceCheck too, which the WWDC26 session does not. Outside Apple, Validate Real Apps With App Attest on The Swift Dev is a recent end-to-end walk-through, and Bruno Rocha's App Attest on SwiftRocks is from 2020, but its explanation of what App Attest does and does not protect against still holds.

Sources

Server libraries, untested by me: