Mobile Attestation & App Integrity
On this page
- The Gate That Browsers Cannot Walk Through
- Google Play Integrity API
- Apple App Attest and DeviceCheck
- Why Browsers Cannot Fake It
- Emulators, Root, and Detection
- Where This Leaves Scrapers
- Two Worlds: Play Integrity and App Attest
- What a Verifier Must Check
- Replay, Nonce, and Freshness
- What Verdicts Are (and Are Not)
- Mobile Versus Web Security Model
- Operational Caveats
The Gate That Browsers Cannot Walk Through#
As browser-based bot defense matured, mobile apps became the softer target—until the platforms themselves shipped attestation. Google and Apple now provide cryptographic APIs that let a server verify a request came from a genuine, unmodified app running on a genuine device. For anti-bot work, attestation is the hardest wall yet, because the proof is rooted in hardware and platform keys that a scraper cannot fabricate.
Google Play Integrity API#
Android's Play Integrity API returns a signed verdict covering:
- App integrity: whether the app binary is the one published by the developer, unmodified.
- Device integrity: whether the device passes Android's compatibility and integrity checks (not rooted, bootloader locked, no emulator).
- Account details: whether the request comes from a licensed install on a real Google account.
The app requests an integrity token from Google Play, receives a signed blob, and sends it to its own backend, which verifies the signature with Google's servers. A scraper replaying API calls without a fresh, valid token is rejected.
Apple App Attest and DeviceCheck#
On iOS, App Attest uses the Secure Enclave to generate a hardware-backed key pair unique to the app installation. The server challenges the app, the app signs the challenge with its attested key, and the server verifies that the key was genuinely generated inside the secure hardware on a real device. DeviceCheck similarly produces a per-device token that apps can use to distinguish genuine devices and detect reinstall abuse.
Why Browsers Cannot Fake It#
The entire security rests on secrets that never leave secure hardware—the Secure Enclave on iOS, the hardware-backed keystore and platform signing on Android. No JavaScript environment, emulator, or HTTP client can mint a valid attestation, because the signing key is not extractable. That is precisely the point: attestation moves trust from software (which can be patched) to hardware (which cannot, short of a physical or platform exploit).
Emulators, Root, and Detection#
Attestation also detects the environments scrapers love: emulators, rooted devices, and modified system images all fail the device-integrity check. This means the classic "run the app in an emulator and intercept its traffic" workflow is often blocked outright. Attackers sometimes try to pass attestation by harvesting tokens from real devices (a token farm) or by exploiting platform bugs—both expensive and quickly patched, and both ethically and legally fraught.
Where This Leaves Scrapers#
Attestation turns some targets into genuine walls. When a mobile API is protected by fresh, server-verified attestation, the honest options are:
- Use the platform's official API or data-licensing program.
- Find a legitimate web or partner channel that is not attestation-gated.
- Accept that the data is not meant to be scraped and choose a different source.
Teams sometimes operate real-device farms with legitimate accounts and genuine tokens to access their own data (for example, automating a service they subscribe to), which is a different and more defensible scenario than forging attestation to evade a platform's controls. Whatever the approach, treat attestation as a clear signal that the platform has drawn a deliberate line—and that crossing it carries real legal and ethical weight.
Two Worlds: Play Integrity and App Attest#
Mobile attestation is where web and mobile anti-bot fundamentally diverge. On the web, you prove a client is a real browser through browser signals and managed challenges; on mobile, you prove the app and the device are genuine before any API call leaves the handset. There are two dominant families, and they do not overlap in the questions they answer.
| Aspect | Play Integrity (Android) | App Attest (iOS) |
|---|---|---|
| Scope | device + app (Google Play context) | device + app (Apple ecosystem) |
| Key material | hardware-backed key attestation (Keystore/StrongBox) | secure enclave attestation |
| What it proves | integrity verdicts (MEETS_DEVICE/APP/STRONG_INTEGRITY) | assertion that a specific key was generated in Secure Enclave and app is unmodified |
| Revocation | Play can revoke via integrity API responses | Apple can revoke, handled via attestation |
| Offline capability | mostly online verification | attestation objects can be verified offline in principle, but freshness still matters |
| Custom apps | sideloaded apps produce different verdicts | sideloaded/TestFlight produce different assertions |
| Web analogue | closest to browser signals and TLS fingerprinting but hardware-bound | closest to a signed assertion with hardware root |
Treat the attestation as a claim, not a proof. The server must verify it: parse the token, validate signatures against the platform's root, check timestamps/nonces, ensure the package name/signing key matches your registered app, and enforce what verdicts you accept. If your backend blindly forwards the token to a third party or trusts a client-side "is_integrity_ok" flag, you have reintroduced the very class of spoof the hardware is meant to prevent.
What a Verifier Must Check#
Verification is tedious for a reason. Each check closes a different replay or spoof:
| Check | Failure means | Remedy |
|---|---|---|
| Key ID and algorithm | unrecognised or downgraded algorithm | reject; pin to the algorithm the platform requires |
| Timestamp freshness | token issued days ago | reject; require now <= issuance + max_age with a small skew window |
| Nonce binding | nonce reused or not the one you issued | reject; see replay below |
| Request binding | token signed for a different endpoint/method/body hash | reject; tie attestation to the intended action |
| App identity | package name, hash, or Team ID wrong | reject; whitelist only your builds |
| Device integrity verdict | device is rooted/emulated or app is modified | reject or challenge per policy |
| License/Play context | sideloaded or unlicensed | reject; do not allow for high-value endpoints |
| Attestation chain | certificate chain invalid or revoked | reject; pin roots or use platform's verification SDK |
| Lifetime | token expires soon; don't let clients reuse | require short-lived tokens and a new one per sensitive action |
A common shortcut is to only check the top-level verdict string. That is insufficient: an attacker can mint a token for a different app with the same verdict class and present it to your API if you do not check app identity. Similarly, checking app identity but not freshness allows replay from hours ago. The checks must all pass.
Replay, Nonce, and Freshness#
The nonce is the most important defence against replay. Issue a server-generated, unpredictable nonce bound to the request context (user, endpoint, action id). The client must include it in the attestation request to the platform, and the resulting token must contain it. Reject if it does not match, and reject if you have seen it before. A nonce cache with a short TTL (a few minutes beyond your token lifetime) is sufficient.
Freshness is two-layered: the platform's timestamp on the token, and your own "issued for this session" constraint. Do not allow a token from an old login to authorise a new sensitive action. For high-value endpoints (withdraw, payment, change of email), require a fresh attestation generated immediately before that action, not one cached from app startup. This is analogous to the "solve immediately before action" rule in managed challenge flows.
What Verdicts Are (and Are Not)#
Verdicts are probabilistic statements about integrity, not guarantees. Understand the mapping:
| Verdict (representative) | Meaning | Practical stance |
|---|---|---|
| STRONG_INTEGRITY | hardware-backed, unmodified app, genuine device, likely not rooted | acceptable for most high-value actions |
| MEETS_DEVICE_INTEGRITY | genuine device, but app integrity weaker | acceptable for low-risk reads; risky for writes |
| MEETS_APP_INTEGRITY | app is unmodified, device posture unknown/weak | acceptable if device risk is separate, but often paired |
| BASIC_INTEGRITY | cannot distinguish from a patched or emulated environment | do not trust for sensitive actions; may be challenged |
| No verdict / error | verification failed or platform unavailable | treat as failure, do not pass through client claims |
Note the asymmetry: Play Integrity can return multiple booleans; App Attest returns an assertion. In both cases, "unknown" is not "pass". The safe default for any action that creates value (money, quota, data exfiltration surface) is to require the strongest verdict you can justify and to log the full verdict set when rejecting, because verdict strings evolve between OS versions.
Mobile Versus Web Security Model#
Mobile attestation shifts the root of trust to the OS vendor's hardware. This has concrete implications:
- Client code is harder to tamper with in a way that survives attestation, but not impossible. Frida, hooking, and modified APKs still exist; they aim to produce valid-looking tokens or to avoid requesting them.
- The attack surface moves to token replay and to the verifier. A bug in your nonce check or in request binding is catastrophic.
- You can bind tokens to server-side session state. Unlike a web challenge cookie that can be moved between browsers, an attestation token is tightly coupled to the request you issued it for.
- It does not remove the need for other signals. Account behaviour, velocity, and risk score and session profiles still matter. Hardware integrity buys you a stronger signal, not immunity.
- Sideloading breaks the chain. If your product must run on sideloaded builds, you cannot rely on Play/App Attest alone; you will need a different strategy.
The following snippet models a minimal verifier that enforces the key points above: pinned algorithm and key id, nonce replay prevention via a cache, freshness window, request binding, and a policy that maps verdict sets to decisions. It also exercises edge cases: sideloading, device-only attestation, basic integrity, and replay.
import hashlib
# A stand-in for the ECDSA chain. The curve maths is not the lesson here; the
# envelope checks around it are, and those are all deterministic.
PINNED_ALG = "ES256"
PINNED_ALGS = (PINNED_ALG,)
KNOWN_KEYS = ("kid-play-7", "kid-play-8")
CLOCK = 1_700_000_000
WINDOW_S = 600
# Play Integrity verdict bits a strict backend may require.
STRONG = ("MEETS_DEVICE_INTEGRITY", "MEETS_STRONG_INTEGRITY", "MEETS_APP_INTEGRITY")
def seal(kid, alg, nonce, issued, request_hash, verdicts, licensing):
body = "{}|{}|{}|{}|{}|{}".format(kid, alg, nonce, issued, request_hash,
",".join(verdicts) + "|" + licensing)
return {
"header": {"alg": alg, "kid": kid},
"payload": {"nonce": nonce, "iat": issued, "request_hash": request_hash,
"verdicts": verdicts, "licensing": licensing},
"sig": hashlib.sha256(body.encode()).hexdigest()[:16],
}
class Backend:
'''What the app's own server does with the token before it authorises a call.'''
def __init__(self, clock, window_s=WINDOW_S):
self.clock = clock
self.window_s = window_s
self.seen = set()
def verify(self, token, request_hash, require=STRONG):
head = token["header"]
pay = token["payload"]
if head["alg"] not in PINNED_ALGS:
return "rejected: alg {!r} is not pinned".format(head["alg"])
if head["kid"] not in KNOWN_KEYS:
return "rejected: key id {!r} is not known".format(head["kid"])
if pay["nonce"] in self.seen:
return "rejected: nonce replayed"
if abs(self.clock - pay["iat"]) > self.window_s:
return "rejected: token is {}s old, window is {}s".format(
self.clock - pay["iat"], self.window_s)
if pay["request_hash"] != request_hash:
return "rejected: token is not bound to this call"
missing = [v for v in require if v not in pay["verdicts"]]
if missing:
return "rejected: verdict missing {}".format(", ".join(missing))
if pay["licensing"] != "LICENSED":
return "rejected: app licensing is {}".format(pay["licensing"])
self.seen.add(pay["nonce"])
return "accepted"
RHO = hashlib.sha256(b"server-nonce-rho").hexdigest()
BASE = dict(kid="kid-play-7", alg=PINNED_ALG, issued=CLOCK - 30,
request_hash=RHO,
verdicts=("MEETS_DEVICE_INTEGRITY", "MEETS_STRONG_INTEGRITY", "MEETS_APP_INTEGRITY"),
licensing="LICENSED")
CASES = [
("fresh token, bound to this call", dict(BASE, nonce="n-a")),
("a second distinct token, same call", dict(BASE, nonce="n-b")),
("token replayed on another call", dict(BASE, nonce="n-c")),
("token issued 40 min ago", dict(BASE, nonce="n-d", issued=CLOCK - 2400)),
("alg stripped to none", dict(BASE, nonce="n-e", alg="none")),
("key id the backend never issued", dict(BASE, nonce="n-f", kid="kid-play-9")),
("rooted device: basic only", dict(BASE, nonce="n-g",
verdicts=("MEETS_BASIC_INTEGRITY", "MEETS_APP_INTEGRITY"))),
("tampered APK, real device", dict(BASE, nonce="n-h",
verdicts=("MEETS_DEVICE_INTEGRITY", "MEETS_STRONG_INTEGRITY"))),
("sideloaded build", dict(BASE, nonce="n-i", licensing="UNLICENSED")),
("that second token, replayed", dict(BASE, nonce="n-b")),
]
LADDER = [
("MEETS_APP_INTEGRITY + MEETS_STRONG_INTEGRITY + LICENSED", "allow", "the only full pass"),
("MEETS_APP_INTEGRITY + MEETS_DEVICE_INTEGRITY", "challenge", "real device, weak hardware posture"),
("MEETS_BASIC_INTEGRITY", "challenge", "cannot tell a real device from a patched one"),
("MEETS_DEVICE_INTEGRITY only", "deny", "genuine hardware, app not proven"),
("anything else, or a 5xx-class error code", "deny", "no verdict is not a pass"),
]
def main():
print("pinned alg {} known keys {} replay window {}s clock {}".format(
PINNED_ALG, ", ".join(KNOWN_KEYS), WINDOW_S, CLOCK))
print()
backend = Backend(CLOCK)
for label, spec in CASES:
token = seal(spec["kid"], spec["alg"], spec["nonce"], spec["issued"],
spec["request_hash"], spec["verdicts"], spec["licensing"])
# Case 3 is a valid token aimed at a different API call.
call_hash = hashlib.sha256(b"order/8842?sku=9931").hexdigest() \
if "another call" in label else RHO
print("{:<34} {}".format(label, backend.verify(token, call_hash)))
print()
print("{:<58} {:<10} {}".format("VERDICT SET", "DECISION", "WHAT IT MEANS"))
print("-" * 104)
for verdicts, decision, meaning in LADDER:
print("{:<58} {:<10} {}".format(verdicts, decision, meaning))
main()
pinned alg ES256 known keys kid-play-7, kid-play-8 replay window 600s clock 1700000000
fresh token, bound to this call accepted
a second distinct token, same call accepted
token replayed on another call rejected: token is not bound to this call
token issued 40 min ago rejected: token is 2400s old, window is 600s
alg stripped to none rejected: alg 'none' is not pinned
key id the backend never issued rejected: key id 'kid-play-9' is not known
rooted device: basic only rejected: verdict missing MEETS_DEVICE_INTEGRITY, MEETS_STRONG_INTEGRITY
tampered APK, real device rejected: verdict missing MEETS_APP_INTEGRITY
sideloaded build rejected: app licensing is UNLICENSED
that second token, replayed rejected: nonce replayed
VERDICT SET DECISION WHAT IT MEANS
--------------------------------------------------------------------------------------------------------
MEETS_APP_INTEGRITY + MEETS_STRONG_INTEGRITY + LICENSED allow the only full pass
MEETS_APP_INTEGRITY + MEETS_DEVICE_INTEGRITY challenge real device, weak hardware posture
MEETS_BASIC_INTEGRITY challenge cannot tell a real device from a patched one
MEETS_DEVICE_INTEGRITY only deny genuine hardware, app not proven
anything else, or a 5xx-class error code deny no verdict is not a pass
The run produces a small decision matrix. Only the case where all three integrity signals are present and licensing is OK gets an allow. Device-only integrity cannot prove the app is yours, so it is denied. Basic integrity is challenged (not denied outright in this policy), but anything with an error code is denied. The replay case is caught explicitly. These are the guardrails you want encoded in code rather than in a runbook.
Operational Caveats#
- Test on real devices and real OS versions. Emulators often return different verdicts; do not rely on them for policy validation.
- Handle platform deprecations. Google and Apple change fields; pin the schema version and fail closed on unknown fields you cannot parse.
- Attestation latency matters. Generating an attestation can add 100-500ms; do it once and reuse only within the constraints above.
- Do not log raw tokens in production. Log hashes, verdict sets, and decision reasons.
- Consider key rotation. Attestation keys can be rotated; your verifier must accept new key IDs while gracefully rejecting unknown ones.
- Mobile and web share the containment lesson. An attestation failure should not immediately ban the device's IP for all users; scope actions to the account and to the request context, as in ban recovery and backoff.
In short: the value of attestation is realised only when verification is strict and context-bound. A lenient verifier is worse than no attestation, because it creates a false sense of security while still consuming the implementation cost.