App Attest and Play Integrity: an end-to-end guide to stopping fake clients

Your API doesn't know who it's talking to. From threat model to data model, from Swift and Kotlin client code to server-side verification in TypeScript, from the decision matrix to a staged rollout, this is how we set up App Attest and Play Integrity in production, step by step.

The API your mobile app talks to is a door open to the internet. The API key you embed in the app, the client-side checks and the obfuscation slow a determined attacker down by a few hours and no more. Anyone who downloads your app can open the binary, watch the traffic and replay your requests byte for byte from a script.

These are the situations we run into most often:

  • Promo and bonus abuse: a new-user campaign gets drained by thousands of fake accounts created on emulator farms.
  • Scraping: competitors pull your price, stock or content APIs on a schedule.
  • Modified clients: ad-free, unlocked or "cheat" builds are passed around and talk straight to your backend.
  • Credential stuffing: leaked password lists are tried against the mobile API, which usually has looser rate limits than the web one.

What they share is one question nobody on the server can answer: does this request really come from our unmodified app, installed from the store, running on a real device? App Attest on iOS and the Play Integrity API on Android answer it at the platform level, with cryptography.

We won't stop at the concepts. What follows is how we set this up end to end in a project: the design decisions, working code, and the traps we have fallen into along the way.

What it solves and what it doesn't

Expectations first. These mechanisms produce a client integrity signal:

Question App Attest Play Integrity
Is the app signed by us and unmodified? Yes (the key is bound to the app identity) Yes (appIntegrity)
Is it a real device? Yes (key generated in the Secure Enclave) Yes, in tiers (deviceIntegrity)
Was the app installed from the store, is it licensed? No Yes (accountDetails)
Are risky apps running on the device? No Optional (environmentDetails)
Who is the user, and are they allowed to do this? No No

The last row is the one to remember: these APIs replace neither authentication nor authorization. On top of that, your genuine app running on a jailbroken iPhone can pass App Attest, and a process hooked at runtime with a tool like Frida can still produce a valid assertion. So we treat the result as a strong signal feeding the risk engine, and never as a standalone gate.

Architecture: one model, two platforms

We put both platforms behind the same server architecture. That keeps decision policy, logging and monitoring in one place.

  1. Challenge service: the server generates a single-use, short-lived (say 5 minutes), unpredictable value and keeps it in a store such as Redis.
  2. Client data: before a sensitive request, the app fetches a challenge and builds a small JSON document that captures the essence of the request: the challenge, the HTTP method, the path and the SHA-256 digest of the body.
  3. Platform proof: on iOS the app obtains an assertion over the hash of that JSON; on Android the same hash goes in as requestHash and the app obtains an integrity token.
  4. Server verification: the server compares the client data with the actual request, consumes the challenge, verifies the platform proof and produces a trust level.
  5. Decision: depending on the endpoint's risk class, the request is allowed, stepped up to extra verification, or denied.

Why send the client data separately? Because we want the proof bound to this request. If you only have the challenge signed, anyone who captures a valid proof can attach it to a different request with a different body. Once the signed JSON includes the body digest, that stops working. The client sends the JSON as is (base64-encoded), and the server hashes the bytes it received. That also spares you from producing identical JSON on both sides, so there is no canonicalization problem to solve.

Endpoints

POST /v1/integrity/challenge          → { "id": "...", "value": "base64url", "expiresAt": "..." }
POST /v1/integrity/ios/register       ← { keyId, attestation, challengeId }   (iOS only, once per key)
POST /v1/orders  (any protected endpoint)
     X-Integrity-Platform: ios | android
     X-Integrity-Client-Data: base64(JSON)
     X-App-Key-Id / X-App-Assertion   (iOS)
     X-Play-Integrity-Token           (Android)

Data model

CREATE TABLE app_attest_keys (
  key_id         TEXT PRIMARY KEY,           -- base64 keyId (SHA-256 of the public key)
  install_id     TEXT NOT NULL,              -- app install / device record
  user_id        TEXT,                       -- if signed in
  public_key_pem TEXT NOT NULL,
  sign_count     BIGINT NOT NULL DEFAULT 0,  -- counter of the last accepted assertion
  environment    TEXT NOT NULL,              -- 'production' | 'development'
  receipt        BYTEA,                      -- Apple receipt, for the risk metric
  created_at     TIMESTAMPTZ NOT NULL DEFAULT now(),
  last_used_at   TIMESTAMPTZ
);

CREATE TABLE integrity_events (            -- for monitoring and decision analysis
  id          BIGSERIAL PRIMARY KEY,
  platform    TEXT NOT NULL,
  endpoint    TEXT NOT NULL,
  trust       TEXT NOT NULL,                -- trusted | degraded | untrusted | unavailable
  reason      TEXT,
  app_version TEXT,
  os_version  TEXT,
  created_at  TIMESTAMPTZ NOT NULL DEFAULT now()
);

iOS: App Attest

Preparation

  • Add the App Attest capability to the target in Xcode. This creates the com.apple.developer.devicecheck.appattest-environment entitlement. Its value is either development or production. Development builds default to the development (sandbox) environment, while builds distributed through TestFlight, the App Store or the Enterprise program use production no matter what the entitlement says. The server has to treat the two environments separately.
  • App Attest doesn't work in the simulator. DCAppAttestService.shared.isSupported returns false there and in some other environments.
  • For large user bases Apple recommends enabling the service in stages. If millions of devices attest on the same day because of one release, you can run into throttling on Apple's side.

Client: an actor that owns the key lifecycle

Key generation, attestation and assertion generation live in one place. The keyId is stored in the Keychain; the key itself stays in the Secure Enclave and never leaves it.

import CryptoKit
import DeviceCheck
import Foundation

struct ClientData: Codable {
    let challenge: String
    let method: String
    let path: String
    let bodySHA256: String
}

enum IntegrityError: Error { case unsupported, attestationRejected }

actor AppAttestManager {
    private let service = DCAppAttestService.shared
    private let api: IntegrityAPI          // the layer that makes the challenge and register calls
    private let keychain: KeychainStore
    private var keyId: String?

    init(api: IntegrityAPI, keychain: KeychainStore) {
        self.api = api
        self.keychain = keychain
    }

    /// Returns an attested key; if there is none, generates one, attests it and registers it with the server.
    func attestedKeyId() async throws -> String {
        if let keyId { return keyId }
        if let saved = keychain.string(for: "appattest.keyId") {
            keyId = saved
            return saved
        }
        guard service.isSupported else { throw IntegrityError.unsupported }

        let newKey = try await service.generateKey()
        let challenge = try await api.fetchChallenge()
        let clientDataHash = Data(SHA256.hash(data: challenge.valueData))
        let attestation = try await attestWithRetry(newKey, clientDataHash: clientDataHash)
        try await api.registerKey(keyId: newKey, attestation: attestation, challengeId: challenge.id)

        keychain.set(newKey, for: "appattest.keyId")
        keyId = newKey
        return newKey
    }

    /// Adds the App Attest headers to the request.
    func sign(_ request: URLRequest) async throws -> URLRequest {
        let keyId = try await attestedKeyId()
        let challenge = try await api.fetchChallenge()
        let clientData = ClientData(
            challenge: challenge.value,
            method: request.httpMethod ?? "GET",
            path: request.url?.path ?? "/",
            bodySHA256: SHA256.hash(data: request.httpBody ?? Data()).hexString
        )
        let clientDataJSON = try JSONEncoder().encode(clientData)
        let hash = Data(SHA256.hash(data: clientDataJSON))

        let assertion: Data
        do {
            assertion = try await service.generateAssertion(keyId, clientDataHash: hash)
        } catch let error as DCError where error.code == .invalidKey {
            // The key is no longer valid (e.g. restore from backup): reset and retry once
            keychain.remove("appattest.keyId")
            self.keyId = nil
            return try await sign(request)
        }

        var signed = request
        signed.setValue("ios", forHTTPHeaderField: "X-Integrity-Platform")
        signed.setValue(keyId, forHTTPHeaderField: "X-App-Key-Id")
        signed.setValue(assertion.base64EncodedString(), forHTTPHeaderField: "X-App-Assertion")
        signed.setValue(clientDataJSON.base64EncodedString(), forHTTPHeaderField: "X-Integrity-Client-Data")
        return signed
    }

    private func attestWithRetry(_ keyId: String, clientDataHash: Data, attempts: Int = 3) async throws -> Data {
        var delay: UInt64 = 500_000_000
        for attempt in 1...attempts {
            do {
                return try await service.attestKey(keyId, clientDataHash: clientDataHash)
            } catch let error as DCError where error.code == .serverUnavailable && attempt < attempts {
                // Transient error: retry with the same key, do not generate a new one
                try await Task.sleep(nanoseconds: delay)
                delay *= 2
            }
        }
        throw IntegrityError.attestationRejected
    }
}

extension SHA256.Digest {
    var hexString: String { map { String(format: "%02x", $0) }.joined() }
}

A few details that matter:

  • serverUnavailable is transient. Apple tells you to try again later with the same key. Generating a new key on every failure inflates the per-device key count and ruins your risk metric.
  • invalidKey usually shows up after the app was restored from a backup or migrated to another device. App Attest keys are bound to the device and don't travel. Your server should treat a new key registration for an existing user as a normal event.
  • Don't use one challenge for two jobs. Registration (attestation) and every protected request (assertion) each get their own challenge.

Server: verifying the attestation

We implement the verification steps from Apple's documentation in Node.js (TypeScript) as shown below. cbor-x handles CBOR decoding and @peculiar/x509 reads the certificate extension. Chain validation and signature checks use Node's own crypto module.

import { createHash, timingSafeEqual, X509Certificate } from "node:crypto";
import { decode } from "cbor-x";
import * as x509 from "@peculiar/x509";

// "Apple App Attestation Root CA" (PEM), downloaded from the Apple PKI page
import { APPLE_APP_ATTEST_ROOT_PEM } from "./apple-root";

const APP_ID = `${process.env.APPLE_TEAM_ID}.${process.env.IOS_BUNDLE_ID}`;
const NONCE_OID = "1.2.840.113635.100.8.2";
const AAGUID_PROD = Buffer.concat([Buffer.from("appattest"), Buffer.alloc(7)]);
const AAGUID_DEV = Buffer.from("appattestdevelop");

const sha256 = (...parts: Buffer[]) => createHash("sha256").update(Buffer.concat(parts)).digest();
const same = (a: Buffer, b: Buffer) => a.length === b.length && timingSafeEqual(a, b);

interface AuthData {
  rpIdHash: Buffer;
  signCount: number;
  aaguid?: Buffer;
  credentialId?: Buffer;
}

// authenticatorData: rpIdHash(32) | flags(1) | signCount(4) | [aaguid(16) | credIdLen(2) | credId | COSE key]
function parseAuthData(buf: Buffer): AuthData {
  const rpIdHash = buf.subarray(0, 32);
  const signCount = buf.readUInt32BE(33);
  if (buf.length <= 37) return { rpIdHash, signCount };
  const aaguid = buf.subarray(37, 53);
  const idLength = buf.readUInt16BE(53);
  const credentialId = buf.subarray(55, 55 + idLength);
  return { rpIdHash, signCount, aaguid, credentialId };
}

// Returns the certificate's EC public key as an uncompressed point (0x04 | X | Y)
function uncompressedPoint(cert: X509Certificate): Buffer {
  const jwk = cert.publicKey.export({ format: "jwk" });
  return Buffer.concat([Buffer.from([0x04]), Buffer.from(jwk.x!, "base64url"), Buffer.from(jwk.y!, "base64url")]);
}

export function verifyAttestation(keyIdB64: string, attestation: Buffer, challenge: Buffer) {
  const att = decode(attestation);
  if (att.fmt !== "apple-appattest") throw new Error("attestation: unexpected fmt");

  // 1. Certificate chain: credCert ← intermediate ← Apple App Attestation Root CA
  const [credDer, intermediateDer] = att.attStmt.x5c as Buffer[];
  const credCert = new X509Certificate(credDer);
  const intermediate = new X509Certificate(intermediateDer);
  const root = new X509Certificate(APPLE_APP_ATTEST_ROOT_PEM);
  const now = Date.now();
  for (const c of [credCert, intermediate]) {
    if (now < Date.parse(c.validFrom) || now > Date.parse(c.validTo)) throw new Error("attestation: certificate expired");
  }
  if (!credCert.verify(intermediate.publicKey) || !intermediate.verify(root.publicKey)) {
    throw new Error("attestation: certificate chain");
  }

  // 2-4. nonce = SHA256(authData || SHA256(challenge)) must match the certificate extension
  const authDataRaw: Buffer = att.authData;
  const nonce = sha256(authDataRaw, sha256(challenge));
  const ext = new x509.X509Certificate(credDer).getExtension(NONCE_OID);
  if (!ext) throw new Error("attestation: nonce extension missing");
  // The extension is DER: SEQUENCE { [1] { OCTET STRING (32 bytes) } }; the last 32 bytes are the nonce
  const certNonce = Buffer.from(ext.value).subarray(-32);
  if (!same(certNonce, nonce)) throw new Error("attestation: nonce mismatch");

  // 5. keyId must be the SHA-256 of the public key
  const keyId = Buffer.from(keyIdB64, "base64");
  if (!same(sha256(uncompressedPoint(credCert)), keyId)) throw new Error("attestation: key id mismatch");

  // 6-9. authenticatorData checks
  const auth = parseAuthData(authDataRaw);
  if (!same(auth.rpIdHash, sha256(Buffer.from(APP_ID)))) throw new Error("attestation: app id mismatch");
  if (auth.signCount !== 0) throw new Error("attestation: counter must be 0");
  const environment = auth.aaguid && same(auth.aaguid, AAGUID_PROD) ? "production"
    : auth.aaguid && same(auth.aaguid, AAGUID_DEV) ? "development" : null;
  if (!environment) throw new Error("attestation: unknown aaguid");
  if (!auth.credentialId || !same(auth.credentialId, keyId)) throw new Error("attestation: credential id mismatch");

  return {
    publicKeyPem: credCert.publicKey.export({ type: "spki", format: "pem" }).toString(),
    receipt: att.attStmt.receipt as Buffer,
    environment,
  };
}

In production, accept only keys from the production environment; development keys belong in a separate test environment. This sample covers Apple's first nine steps. The current documentation adds two more: checking apple_validation_category_01 (whether the build was distributed through the App Store, TestFlight or a development signing identity) and apple_bundle_version_01, both found in the extensions CBOR dictionary at the end of the authenticator data. Confirm with samples from your own devices which OS versions actually send these fields, then add them to your policy. The registration endpoint calls this function after consuming the challenge (with an atomic operation such as GETDEL in Redis) and writes the result to the app_attest_keys table.

Server: verifying the assertion on every request

import { createPublicKey, verify } from "node:crypto";

export function verifyAssertion(stored: { publicKeyPem: string; signCount: number }, assertion: Buffer, clientData: Buffer) {
  const { signature, authenticatorData } = decode(assertion) as { signature: Buffer; authenticatorData: Buffer };

  // nonce = SHA256(authenticatorData || SHA256(clientData)); the signature is ECDSA P-256 / SHA-256
  const nonce = sha256(authenticatorData, sha256(clientData));
  if (!verify("sha256", nonce, createPublicKey(stored.publicKeyPem), signature)) {
    throw new Error("assertion: bad signature");
  }

  const auth = parseAuthData(authenticatorData);
  if (!same(auth.rpIdHash, sha256(Buffer.from(APP_ID)))) throw new Error("assertion: app id mismatch");
  if (auth.signCount <= stored.signCount) throw new Error("assertion: replayed counter");

  return auth.signCount;   // persist the new value once the request is accepted
}

Updating the counter has to be atomic. When two requests arrive concurrently with the same key, use a conditional update so that both cannot be accepted against the old counter:

UPDATE app_attest_keys
   SET sign_count = $2, last_used_at = now()
 WHERE key_id = $1 AND sign_count < $2;
-- if zero rows were affected, reject the request as a replay

Risk metric: how many keys on the same device?

The receipt that comes with the attestation can be sent to Apple's server in exchange for a risk metric: roughly how many keys were attested for your app on that device over the past 30 days. For a normal user the number is low. Many keys from one physical device can point to automation hardware that gets wiped and set up again in a loop. We use this metric as an extra signal in promo and sign-up flows.

Android: Play Integrity API

Preparation

  • Enable the Play Integrity API for your app in Play Console and link it to a Google Cloud project. The client uses that project's cloud project number.
  • On the server you need a service account with the playintegrity scope to decode tokens.
  • The API has a default daily quota. If your traffic will exceed it, ask for an increase ahead of time: the Cloud project must be linked to the app in Play Console, the request goes through Google's quota request form, and processing can take up to a week. Once the quota is exceeded, the client gets a TOO_MANY_REQUESTS error.

Standard or classic?

Standard request Classic request
Use Frequent requests during user interaction Infrequent, high-value actions
Latency Low (the provider is warmed up in advance) Higher
Request binding requestHash nonce
Token decoding Google's servers only Google's servers, or locally with your own keys

Standard requests are enough for most apps. We reach for a classic request only as an additional check on rare but critical actions, account recovery for example.

Client: a class that manages the prepared provider

kotlinx-coroutines-play-services turns the Task API into coroutines. If the provider becomes invalid (INTEGRITY_TOKEN_PROVIDER_INVALID), we prepare it again.

import android.content.Context
import com.google.android.play.core.integrity.IntegrityManagerFactory
import com.google.android.play.core.integrity.StandardIntegrityException
import com.google.android.play.core.integrity.StandardIntegrityManager.PrepareIntegrityTokenRequest
import com.google.android.play.core.integrity.StandardIntegrityManager.StandardIntegrityToken
import com.google.android.play.core.integrity.StandardIntegrityManager.StandardIntegrityTokenProvider
import com.google.android.play.core.integrity.StandardIntegrityManager.StandardIntegrityTokenRequest
import com.google.android.play.core.integrity.model.StandardIntegrityErrorCode
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
import kotlinx.coroutines.tasks.await

class PlayIntegrityGate(context: Context, private val cloudProjectNumber: Long) {
    private val manager = IntegrityManagerFactory.createStandard(context.applicationContext)
    private val mutex = Mutex()
    private var provider: StandardIntegrityTokenProvider? = null

    /** Call at app launch or before a critical flow; safe to call repeatedly. */
    suspend fun prepare(): StandardIntegrityTokenProvider = mutex.withLock {
        provider ?: manager.prepareIntegrityToken(
            PrepareIntegrityTokenRequest.builder()
                .setCloudProjectNumber(cloudProjectNumber)
                .build()
        ).await().also { provider = it }
    }

    /** requestHash: SHA-256 hex digest of the client data JSON */
    suspend fun token(requestHash: String): String {
        return try {
            request(prepare(), requestHash)
        } catch (e: StandardIntegrityException) {
            if (e.errorCode == StandardIntegrityErrorCode.INTEGRITY_TOKEN_PROVIDER_INVALID) {
                mutex.withLock { provider = null }
                request(prepare(), requestHash)   // prepare again and retry once
            } else {
                throw e
            }
        }
    }

    private suspend fun request(p: StandardIntegrityTokenProvider, requestHash: String): String {
        val response: StandardIntegrityToken = p.request(
            StandardIntegrityTokenRequest.builder()
                .setRequestHash(requestHash)
                .build()
        ).await()
        return response.token()
    }
}

If you use OkHttp, the cleanest place to sign requests to protected endpoints is an interceptor. Interceptors are synchronous, so this example uses runBlocking; OkHttp already runs calls on background threads. Make sure the challenge call (api.fetchChallengeBlocking) does not go through the same interceptor.

class IntegrityInterceptor(
    private val gate: PlayIntegrityGate,
    private val api: IntegrityApi,
    private val protectedPaths: Set<String>,
) : Interceptor {
    override fun intercept(chain: Interceptor.Chain): Response {
        val original = chain.request()
        if (original.url.encodedPath !in protectedPaths) return chain.proceed(original)

        val body = Buffer().also { original.body?.writeTo(it) }.readByteArray()
        val challenge = api.fetchChallengeBlocking()
        val clientData = JSONObject()
            .put("challenge", challenge.value)
            .put("method", original.method)
            .put("path", original.url.encodedPath)
            .put("bodySHA256", body.sha256Hex())
            .toString()
            .toByteArray()

        val token = runCatching { runBlocking { gate.token(clientData.sha256Hex()) } }.getOrNull()

        val signed = original.newBuilder()
            .header("X-Integrity-Platform", "android")
            .header("X-Integrity-Client-Data", Base64.encodeToString(clientData, Base64.NO_WRAP))
            .apply { if (token != null) header("X-Play-Integrity-Token", token) }
            .build()
        return chain.proceed(signed)
    }
}

If no token can be obtained we still send the request; the server classifies the missing token as unavailable and leaves the outcome to the decision policy. On devices with no Play Store or an outdated one (PLAY_STORE_VERSION_OUTDATED, PLAY_SERVICES_NOT_FOUND), a client that crashes or spins in an endless retry loop is among the bugs we see most.

Server: decoding the token and reading the verdict

import { google } from "googleapis";
import { createHash } from "node:crypto";

const auth = new google.auth.GoogleAuth({ scopes: ["https://www.googleapis.com/auth/playintegrity"] });
const playintegrity = google.playintegrity({ version: "v1", auth });

const PACKAGE = process.env.ANDROID_PACKAGE!;
const RELEASE_CERT_SHA256 = process.env.ANDROID_CERT_SHA256!;  // SHA-256 digest of the Play signing key (base64url)
const MAX_AGE_MS = 2 * 60 * 1000;

type Trust = "trusted" | "degraded" | "untrusted";

export async function evaluatePlayIntegrity(token: string, clientData: Buffer): Promise<{ trust: Trust; reason: string }> {
  const res = await playintegrity.v1.decodeIntegrityToken({
    packageName: PACKAGE,
    requestBody: { integrityToken: token },
  });
  const p = res.data.tokenPayloadExternal!;

  // Request binding and freshness
  const expectedHash = createHash("sha256").update(clientData).digest("hex");
  if (p.requestDetails?.requestPackageName !== PACKAGE) return { trust: "untrusted", reason: "package" };
  if (p.requestDetails?.requestHash !== expectedHash) return { trust: "untrusted", reason: "request-hash" };
  if (Date.now() - Number(p.requestDetails?.timestampMillis) > MAX_AGE_MS) return { trust: "untrusted", reason: "stale" };

  // App integrity
  const app = p.appIntegrity;
  if (app?.appRecognitionVerdict !== "PLAY_RECOGNIZED") return { trust: "untrusted", reason: `app:${app?.appRecognitionVerdict}` };
  if (!app.certificateSha256Digest?.includes(RELEASE_CERT_SHA256)) return { trust: "untrusted", reason: "signing-cert" };

  // Device integrity
  const device = p.deviceIntegrity?.deviceRecognitionVerdict ?? [];
  if (!device.includes("MEETS_DEVICE_INTEGRITY")) {
    return device.includes("MEETS_BASIC_INTEGRITY")
      ? { trust: "degraded", reason: "basic-integrity-only" }
      : { trust: "untrusted", reason: "device" };
  }

  // Optional environment signals (if enabled in Play Console)
  const risk = p.environmentDetails?.playProtectVerdict;
  if (risk === "HIGH_RISK") return { trust: "degraded", reason: "play-protect-high-risk" };

  return { trust: "trusted", reason: "ok" };
}

Things to keep in mind when reading the verdict fields:

  • appRecognitionVerdict: values other than PLAY_RECOGNIZED (UNRECOGNIZED_VERSION, UNEVALUATED) don't always mean an attack. An internal test build that was never uploaded to Play returns UNRECOGNIZED_VERSION, for example. That is one more reason to keep your test environment separate.
  • deviceRecognitionVerdict is an array. The default label is MEETS_DEVICE_INTEGRITY; MEETS_BASIC_INTEGRITY and MEETS_STRONG_INTEGRITY only arrive if you opt in to them in Play Console (which is why the degraded branch in the code above needs that opt-in). MEETS_VIRTUAL_INTEGRITY is returned only for apps released to Google Play Games for PC, for an emulator with Google Play services. For most apps the threshold should be MEETS_DEVICE_INTEGRITY. Consider STRONG only for the most critical flows such as payments, because on Android 13 and higher it also requires a security update within the last year and shuts out devices that no longer get updates.
  • accountDetails.appLicensingVerdict: check for LICENSED on paid apps or paid content.
  • environmentDetails (appAccessRiskVerdict, playProtectVerdict) and extras such as deviceIntegrity.recentDeviceActivity are enabled separately in Play Console. Apps that capture or control the screen (KNOWN_CAPTURING, UNKNOWN_CONTROLLING and the like) are meaningful in domains such as banking; they shouldn't be grounds for rejection in every app.

Decision policy: don't block right away

Applying "deny if verification fails" to every user in the first release is the most common mistake we see, and the most expensive one. Old devices, custom ROMs, devices without the Play Store and transient service errors hit real users too.

We assign each endpoint a risk class and combine it with the trust level coming from the platform:

Trust level Low risk (catalogue, content) Medium risk (cart, profile) High risk (payment, promo, account changes)
trusted Allow Allow Allow
degraded Allow Allow, flag Step-up verification (OTP, 3-D Secure)
unavailable (no token, service error) Allow Allow, tighten rate limit Step-up verification
untrusted Allow, log Step-up verification Deny

Note that step-up verification doesn't block the user, it raises the cost. An OTP is expensive for a bot; for a real user it is a few seconds.

Staged rollout plan

  1. Week 0, instrumentation: ship the client code, but have the server only verify and write to integrity_events. Reject nothing.
  2. Weeks 1–3, observation: watch the trust level distribution broken down by app version, OS version and device model. If the untrusted rate is high in a segment you didn't expect (one manufacturer's devices, say), understand that first.
  3. Week 4, high-risk endpoints: turn the decision matrix on for the high risk class only. Do it behind a feature flag so you can roll back within minutes if something goes wrong.
  4. Afterwards, expansion: if the metrics look healthy, add the medium risk class. Keep opening App Attest to iOS users in stages.

Metrics we track

  • Attestation and token success rate per platform
  • Trust level distribution (trusted / degraded / untrusted / unavailable)
  • Error code distribution: DCError codes, Play Integrity error codes
  • Verification latency (p95) and the decodeIntegrityToken error rate
  • Daily Play Integrity quota usage
  • The share of requests sent to step-up verification or denied, and its effect on support tickets

Testing

  • iOS: you need a real device. Development builds use the development environment and the aaguid is appattestdevelop (that is the value in Apple's verification steps, while the "Preparing to use the App Attest service" page says appattestsandbox; log what your own device sends and confirm it). Your server's test environment should accept these keys and production should reject them. Keep a few attestation and assertion samples captured from a real device as test fixtures for your verification code, and write a negative test with a corrupted sample for each of the chain, nonce, counter and rpIdHash checks.
  • Android: by creating Play Integrity API tests in Play Console you can produce different verdicts for the test users you select. Builds installed from the internal testing track return PLAY_RECOGNIZED; builds installed straight from Android Studio don't, and that is expected.
  • Server: use automated tests to confirm that a challenge can't be used twice, that an expired challenge is rejected, that a request with a modified body fails the client data check, and that a second assertion with the same counter is rejected.

Firebase App Check or direct integration?

Firebase App Check uses App Attest and Play Integrity as providers and hands you its own short-lived token. It is a good option if you already use Firebase services or want to get going quickly. For your own backend you can verify the App Check token with the Admin SDK and consume "limited-use" tokens to guard against replay.

We prefer direct integration in these cases:

  • The proof has to be bound to the content of the request (the client data approach in this article).
  • You want different decisions depending on verdict detail (device tier, environment signals, risk metric).
  • Verification and decision logic have to stay entirely on your own infrastructure.

Common mistakes

  • Verifying the proof on the client, or trusting the client's own claim that it is trustworthy.
  • Reusing a challenge, leaving it without an expiry, or not binding the proof to the request content.
  • Not checking the assertion counter on iOS, or updating it non-atomically.
  • Accepting App Attest keys from the development environment in production.
  • Not checking the signing certificate digest on Android and relying on the package name alone.
  • Looping forever on transient errors such as serverUnavailable or INTEGRITY_TOKEN_PROVIDER_INVALID, or generating a new key every time.
  • Defining no fallback policy for unsupported devices and denying from day one.
  • Using these mechanisms as a replacement for authentication or authorization.

Frequently asked questions

Should I protect every request? No. Start with the endpoints that are worth the most to the business: sign-up, sign-in, promos, payments, account changes, and the listing endpoints that scrapers go after. Protecting everything costs you both latency and quota.

Doesn't a separate challenge request add latency? It does, which is why we only do it on protected endpoints. A common way to remove the extra round trip is to return the challenge for the next request inside the previous response.

Do I still need jailbreak or root detection? These APIs give a strong signal about device integrity, but they don't always catch runtime hooking. In high-risk apps we keep using the controls under MASVS-RESILIENCE as additional signals.

My web and desktop clients use the same API. What about them? These mechanisms are for mobile clients only. Define a separate trust path for the other clients that hit the same endpoints (bot management for the web, for instance) and split the decision policy by client type.

Checklist

  • The endpoints to protect and their risk classes are defined.
  • The challenge is generated on the server; it is single-use, short-lived and consumed atomically.
  • The proof is bound to the request content (method, path, body digest) through the client data.
  • iOS: attestation checks the chain, nonce, keyId, rpIdHash, counter, aaguid and credentialId.
  • iOS: the assertion counter is updated atomically; production accepts production keys only.
  • Android: requestHash, package name, freshness, PLAY_RECOGNIZED, signing certificate and device tier are checked.
  • There is retry for transient errors and a fallback policy for unsupported devices.
  • You started in observation mode; enforcement is staged and behind a feature flag.
  • Success rates, trust distribution, error codes, latency and quota are monitored.
  • Results are weighed together with rate limiting, the risk metric and behavioural signals.

Need support on this?

Let's review together whether these controls are set up correctly in your app. We're here with security audits and advisory.

Comments and questions

Disagree with something, or stuck on this in your own project? Write to us and let's talk.