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.
- Challenge service: the server generates a single-use, short-lived (say 5 minutes), unpredictable value and keeps it in a store such as Redis.
- 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.
- Platform proof: on iOS the app obtains an assertion over the hash of that JSON; on Android the same hash goes in as
requestHashand the app obtains an integrity token. - Server verification: the server compares the client data with the actual request, consumes the challenge, verifies the platform proof and produces a trust level.
- 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-environmententitlement. Its value is eitherdevelopmentorproduction. Development builds default to thedevelopment(sandbox) environment, while builds distributed through TestFlight, the App Store or the Enterprise program useproductionno matter what the entitlement says. The server has to treat the two environments separately. - App Attest doesn't work in the simulator.
DCAppAttestService.shared.isSupportedreturnsfalsethere 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:
serverUnavailableis 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.invalidKeyusually 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
playintegrityscope 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_REQUESTSerror.
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 thanPLAY_RECOGNIZED(UNRECOGNIZED_VERSION,UNEVALUATED) don't always mean an attack. An internal test build that was never uploaded to Play returnsUNRECOGNIZED_VERSION, for example. That is one more reason to keep your test environment separate.deviceRecognitionVerdictis an array. The default label isMEETS_DEVICE_INTEGRITY;MEETS_BASIC_INTEGRITYandMEETS_STRONG_INTEGRITYonly arrive if you opt in to them in Play Console (which is why thedegradedbranch in the code above needs that opt-in).MEETS_VIRTUAL_INTEGRITYis returned only for apps released to Google Play Games for PC, for an emulator with Google Play services. For most apps the threshold should beMEETS_DEVICE_INTEGRITY. ConsiderSTRONGonly 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 forLICENSEDon paid apps or paid content.environmentDetails(appAccessRiskVerdict,playProtectVerdict) and extras such asdeviceIntegrity.recentDeviceActivityare enabled separately in Play Console. Apps that capture or control the screen (KNOWN_CAPTURING,UNKNOWN_CONTROLLINGand 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
- Week 0, instrumentation: ship the client code, but have the server only verify and write to
integrity_events. Reject nothing. - Weeks 1–3, observation: watch the trust level distribution broken down by app version, OS version and device model. If the
untrustedrate is high in a segment you didn't expect (one manufacturer's devices, say), understand that first. - 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.
- 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:
DCErrorcodes, Play Integrity error codes - Verification latency (p95) and the
decodeIntegrityTokenerror 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
developmentenvironment and the aaguid isappattestdevelop(that is the value in Apple's verification steps, while the "Preparing to use the App Attest service" page saysappattestsandbox; 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
developmentenvironment in production. - Not checking the signing certificate digest on Android and relying on the package name alone.
- Looping forever on transient errors such as
serverUnavailableorINTEGRITY_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
productionkeys 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.
Sources
- Apple · Establishing your app’s integrity
- Apple · Validating apps that connect to your server
- Apple · Preparing to use the App Attest service
- Apple · Assessing fraud risk
- Apple · DCError.Code
- Apple · App Attest environment entitlement
- Apple PKI · App Attestation Root CA
- Android · Play Integrity API overview
- Android · Make a standard API request
- Android · Make a classic API request
- Android · Integrity verdicts
- Android · Play Integrity error codes
- Firebase · App Check
- OWASP MASVS · MASVS-RESILIENCE
Comments and questions
Disagree with something, or stuck on this in your own project? Write to us and let's talk.