Every backend team deciding whether to trust requests from an iOS or Android client hits the same wall: code running on the device can be modified by an attacker. App Attest and Play Integrity are official mechanisms that answer this question on the server, not the client — but wired up wrong, they produce only a false sense of trust. Based directly on Apple's and Android's official docs, this post covers how to verify App Attest and Play Integrity tokens on the server, how challenge/nonce protects against replay attacks, and how to unify both platforms in one backend.
💡 Pro Tip: App Attest and Play Integrity only answer the question "did this request really come from a genuine device?" — they do not authenticate the user. Always add them on top of your existing authentication layer as a separate signal; never use them as a replacement.
Table of Contents
- Threat Model: Fake Clients, Emulators, and Replay Attacks
- The App Attest Flow: Key Generation, Attestation, and Assertion
- From Attestation to Assertion
- Server-Side App Attest Verification Steps
- Summary of Verified Fields
- Interpreting Play Integrity Verdicts
- Play Integrity Server-Side Verification: Standard and Classic
- Nonce and Token Format in the Classic API
- Two Platforms, One Endpoint: A Shared Backend Design
- Replay Protection with Challenge/Nonce
- False Positives and User Experience
- Gradual Rollout and Metrics
- FAQ
- How is App Attest verified on the server?
- How is a Play Integrity token checked on the backend?
- Why is attestation more reliable than jailbreak detection?
- When does an App Attest key become invalid?
- What's the difference between deviceIntegrity and appLicensingVerdict?
- Update (September 2026)
- Conclusion
- Sources
Threat Model: Fake Clients, Emulators, and Replay Attacks
In Apple's own words, a backend can't rely on client code to verify itself: "You can't rely on your app's logic to perform security checks on itself because a compromised app can falsify the results." That sentence sums up why App Attest exists. An attacker has three basic tools: (1) a custom client that mimics the app's API requests, (2) running the real app on an emulator or a rooted/jailbroken device and tampering with the traffic, (3) replaying a previously captured valid request or token. Attestation defends against these with layered controls: a hardware-backed key signature filters fake clients, device integrity signals flag emulators and tampered environments, and a single-use challenge/nonce makes replay attacks significantly harder. If the three don't work together the whole system weakens — a correctly generated nonce offers no protection if the server never checks it.
The App Attest Flow: Key Generation, Attestation, and Assertion
App Attest is a three-stage protocol: key generation, attestation (once), and assertion (every request). First the device generates a hardware-backed key pair with DCAppAttestService, returning a keyId. Then Apple attests that key — this step embeds the hash of a one-time challenge from your server directly into the request: "attestation embeds the hash of a unique, one-time challenge from your server." Apple recommends the challenge be at least 16 bytes for sufficient entropy: "The challenge should be at least 16 bytes in length to ensure sufficient entropy to ensure guessing them is infeasible." On success, Apple returns a certificate chain (x5c) and a receipt; you verify the chain, then store the public key extracted from it and the receipt on your server.
1import DeviceCheck2import CryptoKit3 4func attestNewKey(serverChallenge: Data) async throws -> (keyId: String, attestation: Data) {5 let service = DCAppAttestService.shared6 guard service.isSupported else {7 throw AttestError.notSupported8 }9 let keyId = try await service.generateKey()10 let clientDataHash = Data(SHA256.hash(data: serverChallenge))11 let attestation = try await service.attestKey(keyId, clientDataHash: clientDataHash)12 return (keyId, attestation)13}From Attestation to Assertion
On every API call after attestation, you generate an "assertion" — which again uses a challenge from the server to prevent replay: "You use a challenge here, like for attestation, to avoid replay attacks." Apple notes that generated keys remain valid across normal app updates but become invalid on reinstall, device migration, or restore — so your backend needs to gracefully handle the "key is no longer valid" case without forcing the user back through onboarding.
Server-Side App Attest Verification Steps
Server-side verification is a chain of steps defined in order in Apple's "Validating apps that connect to your server" document. First, verify the x5c certificate chain up to Apple's root certificate. Then append clientDataHash to the end of the authenticator data and re-derive the nonce: "Generate a new SHA256 hash of the composite item to create nonce." This must match exactly the value in the certificate's OID 1.2.840.113635.100.8.2 extension. Next, verify the public key matches the keyId, and that the SHA256 hash of the App ID matches the authenticator data's RP ID hash: "Compute the SHA256 hash of your app's App ID, and verify that it's the same as the authenticator data's RP ID hash." Then verify the counter is zero and the aaguid matches the environment. It doesn't stop there: also check that credentialId matches the key identifier, and the apple_validation_category_01 and apple_bundle_version_01 values in the extensions CBOR dictionary.
1// Node.js — App Attest verification skeleton (simplified)2import { createHash } from "node:crypto";3 4function verifyAppAttestCounterAndEnv(5 authData: AuthenticatorData,6 env: "production" | "development",7) {8 // 1) counter field must be 09 if (authData.counter !== 0) {10 throw new Error("Replay suspected: counter is not 0");11 }12 // 2) aaguid must match the environment13 const expected =14 env === "development"15 ? "appattestdevelop"16 : "appattest\u0000\u0000\u0000\u0000\u0000\u0000\u0000";17 if (authData.aaguid !== expected) {18 throw new Error("aaguid does not match the expected value");19 }20}21 22function deriveNonce(authData: Buffer, clientDataHash: Buffer): Buffer {23 return createHash("sha256")24 .update(Buffer.concat([authData, clientDataHash]))25 .digest();26}Apple recommends storing the public key and the receipt immediately once attestation succeeds: "When attestation succeeds, independently verify and store the receipt immediately." It also advises, as an additional safeguard against replay attacks, checking that the same public key hasn't already been associated with another user account: "As an added protection against replay attacks, make sure that the public key doesn't already have an association with another user." If you skip this check, an attacker can reuse a stolen key across different accounts.
Summary of Verified Fields
You also need to know that in the sandbox environment the aaguid field carries a different value — Apple's sandbox attestations expect "appattestsandbox". The table below summarizes the fields App Attest checks on the server side and the values expected.
Check Field | Expected Value | Purpose |
|---|---|---|
Certificate chain (x5c) | Valid up to Apple's root certificate | Proves the client went through the real Apple attestation service |
Nonce (OID 1.2.840.113635.100.8.2) | SHA256 of authData + clientDataHash | Verifies the request is bound to the challenge the server generated |
RP ID hash | SHA256 hash of the App ID | Verifies the attestation belongs to the correct app |
counter | 0 (only on the first attestation) | Shows the attestation is a first use, not cloned |
aaguid | appattest / appattestdevelop / appattestsandbox | Verifies the environment (prod/dev/sandbox) |
Interpreting Play Integrity Verdicts
Play Integrity returns multiple verdict fields instead of a single pass/fail flag; the backend must interpret them together. deviceIntegrity shows whether the device is genuine and certified; the docs define MEETS_DEVICE_INTEGRITY as: "The app is running on a genuine and certified Android device." On Android 13+, this is reinforced with hardware-backed proof the bootloader is locked. A PLAY_RECOGNIZED value in appRecognitionVerdict verifies the app's signing certificate matches the version Google Play distributes: "The app and certificate match the versions distributed by Google Play." A LICENSED value in appLicensingVerdict shows the user actually installed or updated the app through Play: "The user installed or updated your app from Google Play on their device."
These three groups should be evaluated independently: even a MEETS_DEVICE_INTEGRITY device can return UNLICENSED for appLicensingVerdict, which usually means the app was sideloaded from outside Play — the device is trustworthy, but the distribution channel isn't. The table below summarizes the core verdict groups of the Play Integrity Standard API.
Verdict Group | Example Value | What It Means |
|---|---|---|
deviceIntegrity | MEETS_DEVICE_INTEGRITY | Device is genuine, certified, bootloader locked |
appRecognitionVerdict | PLAY_RECOGNIZED | Signing certificate matches the Play distribution |
appLicensingVerdict | LICENSED | App was installed/updated through Play |
appLicensingVerdict | UNLICENSED | App may have come from a source outside Play |
Play Integrity Server-Side Verification: Standard and Classic
Play Integrity offers two APIs: Standard (request-based, with requestHash) and Classic (nonce-based, nested JWE/JWS token). In Standard, a token generated without requestHash is bound only to the device, not the specific request — opening an attack risk: "Without the requestHash, the integrity token will be bound only to the device, but not to the specific request, which opens up the possibility of attack." Google Play automatically prevents the same token being reused: "Google Play automatically prevents integrity tokens from being reused many times." Server-side verification is a POST to playintegrity.googleapis.com/v1/PACKAGE_NAME:decodeIntegrityToken.
Nonce and Token Format in the Classic API
In Classic, the nonce must be URL-safe Base64 encoded, 16–500 characters; the docs list: "String, URL-safe, Encoded as Base64 and non-wrapping, Minimum of 16 characters, Maximum of 500 characters." The token is nested — a JWS inside a JWE (A256KW/A256GCM encryption + ES256 signature): "The token is a nested JSON Web Token (JWT), that is JSON Web Encryption (JWE) of JSON Web Signature (JWS)." Important: data in the nonce and requestHash fields is visible in cleartext to your app and to Google — never put directly sensitive data (like a raw password) there: "Data that you use for the requestHash and nonce fields is visible in cleartext to your app, and to Google."
1// Android client — Play Integrity Standard API request2val standardIntegrityManager = IntegrityManagerFactory.createStandard(applicationContext)3val requestHash = sha256Base64(requestBodyBytes) // hash of the request coming from the server4 5val request = StandardIntegrityManager.PrepareIntegrityTokenRequest.builder()6 .setCloudProjectNumber(CLOUD_PROJECT_NUMBER)7 .build()8 9standardIntegrityManager.prepareIntegrityToken(request)10 .addOnSuccessListener { tokenProvider ->11 val tokenRequest = StandardIntegrityManager.StandardIntegrityTokenRequest.builder()12 .setRequestHash(requestHash)13 .build()14 tokenProvider.request(tokenRequest)15 .addOnSuccessListener { response ->16 sendTokenToServer(response.token())17 }18 }Two Platforms, One Endpoint: A Shared Backend Design
App Attest and Play Integrity are completely different at the protocol level (one is an attestation object with a certificate chain you verify, the other an encrypted token you have Google decode) — but on the backend they answer the same question: "did this request come from a genuine, unmodified client?" So it's practical to unify them under one verifyDeviceIntegrity(platform, token, expectedChallenge) abstraction — the service layer branches by platform, but the business logic above it (user flow, error codes, metrics) stays common.
1type IntegrityVerdict = {2 trusted: boolean;3 reason?: string;4 platform: "ios" | "android";5};6 7async function verifyDeviceIntegrity(8 platform: "ios" | "android",9 token: string,10 expectedChallenge: string,11): Promise<IntegrityVerdict> {12 if (platform === "ios") {13 return verifyAppAttestAssertion(token, expectedChallenge);14 }15 return verifyPlayIntegrityToken(token, expectedChallenge);16}17 18// Shared middleware: both platforms use the same error contract19app.post("/api/sensitive-action", async (req, res) => {20 const verdict = await verifyDeviceIntegrity(21 req.body.platform,22 req.body.integrityToken,23 req.session.challenge,24 );25 if (!verdict.trusted) {26 return res27 .status(403)28 .json({ error: "device_integrity_failed", reason: verdict.reason });29 }30 // continue with business logic31});The thing to watch for in this design is that the two platforms carry the "challenge" concept differently: in App Attest the challenge is embedded directly in the attestation/assertion, while in Play Integrity Standard it's attached to the request as requestHash. You need to derive both from a short-lived, single-use value the server generates — otherwise the security guarantee underneath the shared abstraction disappears.
Replay Protection with Challenge/Nonce
On both platforms replay protection rests on the same principle: the server generates a new, unpredictable value each round; the client embeds it into a signed/encrypted structure; the server checks the result is bound to the value it generated. Apple applies this at both the attestation and assertion steps — attestation embeds the hash of the server's challenge, and assertion also uses a challenge to prevent replay. In Play Integrity, the same role is played by requestHash in Standard and nonce in Classic.
In practice, keep a short-lived challenge store on your server: right before an action, the user fetches a random value with GET /api/challenge; the client uses it when generating the attestation/token; the server checks it hasn't been used before and hasn't expired (say, 2–5 minutes). Deleting the challenge immediately after use (single-use invalidation) prevents the same token being resent — Google's guarantee that tokens are "automatically prevented from being reused many times" isn't a reason to skip your own challenge management; the two layers complement, not replace, each other.
False Positives and User Experience
Attestation/integrity verification is costly for a product when it mistakenly blocks real users. The most common false-positive source is the transition state where the key becomes invalid: Apple notes App Attest keys stay valid across normal app updates but invalidate on reinstall, device migration, or restore from backup. The server should route the user into a "re-attestation" flow rather than rejecting outright — otherwise a legitimate user feels locked out just for switching phones.
Similar care applies on Android: appLicensingVerdict: UNLICENSED alone doesn't mean "malicious user" — developer test devices, internal distribution, or regional Play restrictions can produce the same result. That's why I prefer a graded trust score over a hard allow/deny pair: block entirely if deviceIntegrity fails, and only ask for an extra step (e.g. email confirmation) if appLicensingVerdict looks suspicious.
- Key invalidity: After a reinstall, device migration, or restore, the old App Attest key becomes invalid — don't reject the user, silently re-attest.
- Suspicious but not certain:
appLicensingVerdict: UNLICENSEDalone isn't proof of an attack — respond gradually. - Hard block: Stop the transaction if
deviceIntegritydoesn't return any MEETS_* value, or if the App Attest certificate chain can't be verified.
Gradual Rollout and Metrics
Opening attestation to the whole user base overnight is risky — bugs in your verification code and edge cases at scale can both block real users. Apple suggests a gradual, uniform rollout for large apps: "we suggest gradually and uniformly ramping up no more than 10 million users per day per app." That figure is an upper bound for the largest apps, but the principle applies at small scale too: open attestation to a small user slice first, watch the failure rate, then expand gradually.
The most critical metric is the failure rate broken down by platform, app version, and OS version — a single global chart hides a problem exploding in one device family or an older app version. I generally roll out attestation first in "log only, don't block" mode and watch the real failure distribution in live traffic — this separates a code bug from a real attack before you switch to hard blocking.
GOLDEN TIP
The most valuable insight in this article
This tip holds the article's most important takeaway.
Easter Egg
You found a hidden gem!
There's a hidden detail in this section. Want to uncover it?
Reader Reward
I gathered, in one place, the checkpoints that are easy to skip but critical when setting up server-side attestation verification. You can use this list as a checklist during code review; each item corresponds to a step explained above, together with its source.
FAQ
How is App Attest verified on the server?
The server first verifies the x5c certificate chain in the attestation object up to Apple's root certificate, then appends clientDataHash to the end of the authenticator data, derives a nonce with SHA256, and compares it against the value in the certificate's OID 1.2.840.113635.100.8.2 extension. It then matches the App ID's hash against the RP ID hash, and the public key against the keyId; finally it checks that the counter field is 0 and that the aaguid matches the environment you're running in (production/development/sandbox).
How is a Play Integrity token checked on the backend?
In the Standard API, the client generates a token by putting the hash of the request it got from the server into the requestHash field; this token is decoded by a POST to the playintegrity.googleapis.com/v1/PACKAGE_NAME:decodeIntegrityToken endpoint. The deviceIntegrity, appRecognitionVerdict, and appLicensingVerdict fields in the response are evaluated separately; if you're using the Classic API, the token is a nested structure — a JWS inside a JWE — and the nonce field must be 16-500 characters, URL-safe Base64.
Why is attestation more reliable than jailbreak detection?
Jailbreak/root detection is a check that runs on the client side and can therefore be bypassed by a modified app — it can simply lie to the server and say "no jailbreak." App Attest and Play Integrity, on the other hand, use hardware-backed keys and an attestation chain signed by the platform provider (Apple/Google); the result isn't produced by client code — it's produced and signed directly by the OS/hardware security component, which makes it far harder to forge.
When does an App Attest key become invalid?
According to Apple, generated keys remain valid across normal app updates but become invalid when the app is deleted and reinstalled, when the device is migrated, or when restored from a backup. In these cases the server needs to start a new attestation flow instead of rejecting the user.
What's the difference between deviceIntegrity and appLicensingVerdict?
deviceIntegrity shows whether the device is a genuine, certified Android device (including bootloader lock); appLicensingVerdict shows whether the app was installed through Google Play. A device can be trustworthy (MEETS_DEVICE_INTEGRITY) while the app was installed from a source outside Play (UNLICENSED) — these two signals don't substitute for each other.
Update (September 2026)
This post's body describes the API surface as it stood in November 2025. As of September 2026, there's no breaking change or announced forced migration in Apple's official docs for the App Attest/DeviceCheck server verification flow (the challenge/assertion/counter/aaguid model); the core contract appears unchanged since publication. On Android, Play Integrity's core contract (Standard/Classic split, requestHash/nonce mechanics, decodeIntegrityToken flow) also appears unchanged; the optional signals listed in the current docs — appAccessRiskVerdict, playProtectVerdict, recentDeviceActivity, and beta-stage deviceRecall — are outside this post's scope but already existed at publication. The one real post-publication change is on the client library side: per the release notes, version 1.6.0, released on 20 November 2025, added the webViewRequestMode parameter to PrepareIntegrityTokenRequest. If you're planning a new integration, re-check the current Play Integrity docs before adding these optional fields — the three core verdict groups covered here (deviceIntegrity, appRecognitionVerdict, appLicensingVerdict) remain the core contract.
Conclusion
App Attest and Play Integrity give a reliable server-side answer to "did this request come from a genuine device?" — but that trust holds only as long as you implement every verification step and run challenge/nonce correctly. Skip the certificate chain, forget the counter check, or let the challenge live too long, and the system may look like it's working while its real protection has evaporated. Treat these two as part of a broader defense-in-depth strategy alongside protecting sensitive data with Keychain security and hardening the transport layer with network traffic security; iOS security best practices shows how these pieces fit together. Don't neglect privacy disclosures either — iOS privacy manifests and App Tracking and App Tracking Transparency compliance cover the other mandatory layers alongside attestation. To automate token verification on the backend, see App Store Connect API automation.
Sources
- Establishing your app's integrity — App Attest's core flow, the challenge/attestation/assertion steps.
- Validating apps that connect to your server — The official reference for server-side App Attest verification steps.
- Preparing to use the App Attest service — Gradual rollout recommendation and usage limits.
- Play Integrity verdicts — Full list of deviceIntegrity, appRecognitionVerdict, and appLicensingVerdict fields.
- Play Integrity Standard API — requestHash mechanics and the decodeIntegrityToken endpoint.
- Play Integrity Classic API — nonce format and the nested JWT (JWE/JWS) token structure.
- Play Integrity API overview — Warning about the cleartext visibility of the nonce/requestHash fields.
Tags
iOS Development News
Weekly Swift tips, SwiftUI tricks and iOS best practices. No spam, only valuable content.
We respect your privacy. You can unsubscribe at any time.

