# Server-Side App Attest and Play Integrity Verification in Python: Open Your Paid AI API Only to Genuine App Installs

> Verify Apple App Attest and Google Play Integrity on your server in Python, from a shipped App Store app: attestation, assertions, counters, requestHash, AI cost caps, fail-open vs fail-closed, and tests.

- Published: 2026-09-25
- Author: 友田 陽大
- Tags: セキュリティ, iOS, Android, Python, AWS, AI, コスト最適化, 信頼性, テスト
- URL: https://tomodahinata.com/en/blog/app-attest-play-integrity-server-verification-ai-api-abuse-guide
- Category: モバイルアプリ開発（Expo / React Native）
- Pillar guide: https://tomodahinata.com/en/blog/expo-production-guide-router-eas-cng-ota

## Key points

- An API key shipped inside the app, or a UUID the device mints for itself, is not a defense: anyone can extract the first and anyone can generate the second. What you need is for the server to verify, cryptographically, that the call comes from a genuine install of your genuine app. On iOS that is App Attest; on Android it is Play Integrity.
- Server-side App Attest verification rests on three things: spend the single-use challenge before you verify anything, pin Apple's root certificate as a file, and advance the assertion counter with a conditional write so it only ever goes up. Drop any one of them and replays get through.
- With Play Integrity, put a hash of the device ID plus a server challenge into requestHash, so the token is only good for that one ID. Attackers can burn the decode quota (10,000 per day by default, shared across the whole project), so running out of it must not be treated as an outage that lets requests through.
- Device attestation does not cap your bill. Alongside per-user quotas, put one environment-wide daily AI call ceiling in a single place, and page a human at 80% and again when it is exhausted.
- Apple's own Attestation Object Validation Guide now ships a real attestation sample. Run through this verifier, it passes the certificate chain, App ID, counter and key ID checks. But the leaf certificate is valid for only three days, and the sample's clientDataHash contradicts the guide's own prose. To use it as a test fixture, pin the clock and hash the challenge the same way your client does.

---

Every call to a generative AI model costs money. Offer a free tier and the operator pays that bill. And the free tier's API will be called **from scripts, not from your app**.

I build and run [MemoryHack, an AI app that turns photos of your study material into flashcards](https://apps.apple.com/app/id6759282044?pt=126890987&ct=tomodahinata_blog_en&mt=8), on my own; it has been on the App Store since August 2026. You photograph a page, an AI writes question-and-answer and fill-in-the-blank cards, and the app schedules them with FSRS or SM-2 spaced repetition. The app's interface is in Japanese. (The wider build story is in the [MemoryHack AI write-up](/labs/memoryhack-ai).) The app has a free guest tier that works without signing in. This article walks through the server code I wrote to **accept AI calls on that guest tier only from genuine installs of the genuine app**, and uses it to show how to verify Apple **App Attest** and Google **Play Integrity** in Python.

The client side, calling App Attest from Swift, is covered in the [React Native / Expo × Swift native module guide](/blog/react-native-expo-swift-native-module-bridge-guide). This article picks up where that one stops: **how the server verifies the proof it receives, what it stores, and how it behaves when things fail.**

> **Baseline**: protocol details follow Apple Developer's DeviceCheck documentation (as retrieved in September 2026) and Android Developers' Play Integrity documentation (standard request page last updated 2026-06-01, verdicts 2026-05-01, setup 2026-09-16). The implementation runs on Python 3.13 (the AWS Lambda `python3.13` runtime) with `cryptography` 50.0.0, `cbor2` 5.9.0 and DynamoDB. Code excerpts come from the main branch of MemoryHack's private repository as of 2026-09-25; sources are cited by file name.

> **Scope, stated plainly**: the device-attestation gate described here covers **only the free guest tier** (generation and the AI tutor for people who have not signed in). Signed-in users are identified by their account and paying users by their subscription, so neither goes through the gate. The gate also has three modes, `off`, `monitor` and `enforce`, and **production has not been moved to `enforce` (the mode that actually refuses requests)**. The repository's debt tracker (`backend/DEBT_TRACKER.md`, entry DEF-6) holds that step back until the shipped app version and the decision procedure line up. I have not checked, for this article, whether production currently runs `off` or `monitor`. The Android build is in internal testing and is not on Google Play, so the Play Integrity part is implemented and tested but has no production track record yet.

## 0. The short answer: three layers

| Layer | What it establishes | What it cannot establish | Where it lives |
|---|---|---|---|
| ① Device attestation (App Attest / Play Integrity) | The request comes from **our signed app** on a genuine device, and is tied to this device ID | How many IDs one device creates | `services/infra/attestation_service.py` |
| ② Per-user quota | How many calls one ID may make per day | How many IDs get created | `core/quota.py` |
| ③ Environment-wide daily ceiling | The most LLM spend you can incur in a day | Who spent it | `services/ai/spend_guard.py` |

Layer ① alone cannot stop someone who regenerates keys on one real device to mint IDs. Layer ② alone cannot stop IDs from being minted at all. Layer ③ ignores who is calling, so even when ① and ② are beaten, **the bill still has a hard limit**. Each layer assumes the others can fail.

## 1. Why an API key in the app is no defense

Start with the common misconception. Shipping the LLM API key in the app and calling the model directly is out of the question: the key sits inside a binary that sits on the attacker's machine. Moving the key to your server only changes the shape of the problem. How does the server's free-tier endpoint decide that a call "comes from the app"?

A MemoryHack guest is identified by a UUID the app generates for itself and sends in an `X-Device-ID` header. Anything the app generates, anyone can generate. A code comment records what that meant on the development environment: **six freshly generated UUIDs each received a full free allowance** (2026-09-17; `backend/services/ai/spend_guard.py`). A loop that mints UUIDs collects free generations without limit.

What you need is **a value an attacker cannot produce on their own machine**. Comparing the options:

| Mechanism | Can an attacker produce it locally? | Tied to the device ID? | Notes |
|---|---|---|---|
| API key embedded in the app | Yes (it can be extracted) | No | Not a defense |
| Device-generated UUID | Yes | — | An identifier, not a proof |
| Apple DeviceCheck token | No (needs a genuine Apple device) | **No** (you cannot include your own data in it) | One genuine device can vouch for any number of IDs |
| **Apple App Attest** | No (needs a Secure Enclave key) | **Yes** (the device signs a challenge you issued) | Keys are per install |
| **Google Play Integrity (standard)** | No (needs a Google-signed verdict) | **Yes** (you choose what goes into `requestHash`) | The server has Google decrypt the verdict |

The column that matters is "tied to the device ID". DeviceCheck proves that a genuine Apple device produced the token, but you cannot put your own data into it, so a single iPhone's token can be stapled onto one throwaway ID after another. MemoryHack actually had a bug where a DeviceCheck verdict overrode an App Attest rejection; it was caught in review and fixed (recorded under DEF-6). The design principle of this article follows from that: **only a mechanism that can prove the tie to the ID gets the final say.**

## 2. App Attest: issuing and spending challenges

App Attest has two phases (Apple, "Establishing your app's integrity"):

```text
Once per install:   challenge → generateKey → attestKey → send the attestation to the server
Per metered call:   challenge → generateAssertion → send it in request headers
```

Both phases start with **a single-use challenge issued by the server**. Apple's documentation asks for a random value that you remember so you can check it when the attestation or assertion comes back.

### 2-1. Issuing: 32 bytes, 300 seconds, one row per challenge

```python
# backend/services/infra/app_attest_store.py (excerpt)
CHALLENGE_TTL_SECONDS = 300
_CHALLENGE_BYTES = 32

async def issue_challenge(device_id: str) -> bytes:
    challenge = secrets.token_bytes(_CHALLENGE_BYTES)
    now = int(time.time())
    await asyncio.to_thread(
        get_main_table().put_item,
        Item={
            **_challenge_key(device_id, challenge),  # PK=DEVICE#{id}, SK=ATTEST_CHALLENGE#{b64}
            "expires_at": now + CHALLENGE_TTL_SECONDS,
            "TTL": now + CHALLENGE_TTL_SECONDS,
        },
    )
    return challenge
```

Two decisions are built in here.

- **A device can hold several challenges at once.** The first version kept one row per device and overwrote it on every issue. When two metered POSTs went out together, the second challenge invalidated the first and a genuine device got refused. Now every issue gets its own row.
- **Expiry is not left to DynamoDB TTL.** TTL deletion is a background sweep; AWS's documentation only says expired items are "typically" deleted "within a few days after their expiration". Relying on TTL alone would leave an expired challenge spendable for days, so `expires_at` is part of the condition checked when the challenge is spent.

### 2-2. Spending: before verification, with a conditional write

```python
# backend/services/infra/app_attest_store.py (excerpt)
async def consume_challenge(device_id: str, challenge: bytes) -> bool:
    # Every issued challenge is exactly 32 bytes, so nothing else can be spendable.
    # Checking the length first also stops an oversized value from blowing
    # DynamoDB's 1 KB sort-key limit and surfacing as UNAVAILABLE (= allow).
    if len(challenge) != _CHALLENGE_BYTES:
        return False
    token = secrets.token_hex(16)
    try:
        await asyncio.to_thread(
            get_main_table().update_item,
            Key=_challenge_key(device_id, challenge),
            UpdateExpression="SET #spent = :token",
            ConditionExpression=(
                "attribute_exists(PK) AND attribute_not_exists(#spent) AND #exp > :now"
            ),
            ExpressionAttributeNames={"#spent": "spent_by", "#exp": "expires_at"},
            ExpressionAttributeValues={":token": token, ":now": int(time.time())},
            ReturnValuesOnConditionCheckFailure="ALL_OLD",
        )
    except ClientError as exc:
        if refused_by_own_write(exc, attribute="spent_by", token=token):
            return True   # this call's first send already spent it; only the answer was lost (section 10)
        if is_conditional_check_failed(exc):
            return False
        raise AppAttestStoreError("challenge_consume_failed") from exc
    return True
```

Both callers (key registration in `routers/attestation.py` and assertion checks in `attestation_service.py`) spend the challenge **before any cryptographic verification**. In the other order, a stolen attestation or assertion could be ground against one live challenge until something passed. Spending first caps it at one try per challenge.

The condition starts with `attribute_exists(PK)` for a reason too. `UpdateItem` **creates** the item when none exists. Without that clause, a challenge the server never issued would produce a fresh "spent" row, and the call would report success.

## 3. App Attest: verifying the attestation, step by step

At key registration (`POST /attestation/keys`) the server receives a CBOR-encoded `{fmt, attStmt: {x5c, receipt}, authData}`, where `authData` follows the WebAuthn Authenticator Data layout. Apple's "Validating apps that connect to your server" lists numbered verification steps. MemoryHack's `verify_attestation` maps each one to a failure reason, a short slug that goes to the logs and never to the client:

| Apple step | Check | Failure reason in code |
|---|---|---|
| 1 | `x5c` holds the leaf and intermediate; validate up to the App Attest root | `chain_length` / `chain_bad_signature` / `chain_expired` |
| 2–3 | `clientDataHash = SHA256(challenge)`, `nonce = SHA256(authData ‖ clientDataHash)` | — |
| 4 | The OCTET STRING in the leaf's extension OID `1.2.840.113635.100.8.2` equals `nonce` | `nonce_mismatch` / `nonce_ext_*` |
| 5 | SHA-256 of the leaf's public key (X9.62 uncompressed point) equals the key ID | `key_id_mismatch` |
| 6 | `SHA256("<TeamID>.<BundleID>")` equals the RP ID in `authData` | `app_id_mismatch` |
| 7 | `counter` is 0 | `counter_not_zero` |
| 8 | `aaguid` is `appattest` + seven `0x00` bytes in production, `appattestdevelop` in development | `aaguid_mismatch` |
| 9 | `credentialId` equals the key ID | `cred_id_mismatch` |

Here is the code, with the type-narrowing helpers omitted:

```python
# backend/services/infra/app_attest_service.py (excerpt)
_NONCE_OID: Final = x509.ObjectIdentifier("1.2.840.113635.100.8.2")
_AAGUID_PRODUCTION: Final = b"appattest\x00\x00\x00\x00\x00\x00\x00"
_AAGUID_DEVELOPMENT: Final = b"appattestdevelop"

def verify_attestation(*, key_id, attestation, challenge, team_id, bundle_id,
                       production, now=None) -> AttestedKey:
    now = now or datetime.now(UTC)
    obj = _decode_map(attestation, "attestation_not_cbor")
    _require(_field(obj, "fmt", str, "fmt_type") == "apple-appattest", "fmt_unexpected")
    statement = _field(obj, "attStmt", dict, "att_stmt_type")
    data = _field(obj, "authData", bytes, "auth_data_type")
    x5c = _field(statement, "x5c", list, "x5c_type")

    # 1. Chain up to the pinned root
    certs = [x509.load_der_x509_certificate(bytes(der)) for der in x5c]
    cred_cert = _verify_chain(certs, now)

    # 2-4. nonce = SHA256(authData || SHA256(challenge)), stamped in the leaf
    client_data_hash = hashlib.sha256(challenge).digest()
    expected_nonce = hashlib.sha256(data + client_data_hash).digest()
    _require(_extension_nonce(cred_cert) == expected_nonce, "nonce_mismatch")

    # 5. The key ID is the SHA-256 of the public key
    leaf_key = _uncompressed_public_key(cred_cert)
    _require(hashlib.sha256(leaf_key).digest() == key_id, "key_id_mismatch")

    # 6-9. Our app, a fresh key, the expected environment
    expected_rp_id = hashlib.sha256(app_id(team_id, bundle_id).encode()).digest()
    _require(data[0:32] == expected_rp_id, "app_id_mismatch")
    _require(int.from_bytes(data[33:37], "big") == 0, "counter_not_zero")
    _require(data[37:53] == (_AAGUID_PRODUCTION if production else _AAGUID_DEVELOPMENT),
             "aaguid_mismatch")
    cred_id_length = int.from_bytes(data[53:55], "big")
    cred_id = data[55 : 55 + cred_id_length]
    _require(len(cred_id) == cred_id_length and cred_id == key_id, "cred_id_mismatch")

    return AttestedKey(key_id=key_id,
                       public_key_der=_public_key(cred_cert).public_bytes(
                           Encoding.DER, PublicFormat.SubjectPublicKeyInfo),
                       counter=0)
```

The deliberate choices:

**Pin the root certificate as a file.** `core/certs/apple_app_attest_root_ca.pem` is committed to the repo. As the code comment puts it, a trust anchor resolved over the network is not an anchor. I re-checked the fingerprint myself: the pinned file and the `Apple_App_Attestation_Root_CA.pem` Apple publishes both hash to `1C:B9:82:3B:…:42:C9:32` (`openssl x509 -noout -fingerprint -sha256`), valid 2020-03-18 to 2045-03-15. The test `test_pinned_root_is_apples` pins the subject and fingerprint.

**Check expiry on every link, the root included.** A chain check that only verifies signatures happily accepts certificates Apple has aged out.

**Accept ECDSA only.** Apple's chain is a P-384 root, a P-384 intermediate and a P-256 leaf. Accepting other key types only widens what a forged chain may present.

**Don't grow an ASN.1 parser.** The nonce extension has a fixed shape, `SEQUENCE { [1] { OCTET STRING } }`, so a dozen-line reader for short-form DER does the job and long-form input is refused. That is a much smaller attack surface than pulling in a general parser.

**Never accept the development `aaguid` in production.** Accepting it would let any development-signed build (anyone with Xcode and your team certificate) attest as if it were the App Store build. The settings class refuses to start in production with `app_attest_production=False`, and a Terraform precondition stops the same combination at `plan` time.

**Never tell the client which check failed.** That is free help for a forger, and an honest client can only ever act on "try again". The router collapses every failure into one 403 and logs the reason slug, which is how operators tell a misconfigured Team ID from an actual forgery.

## 4. What Apple's official sample revealed

MemoryHack's tests (`backend/tests/services/infra/test_app_attest_service.py`) open by explaining that there is no Apple-signed sample to test against, so they build a structurally identical chain under a synthetic root and swap the pinned anchor.

When I re-read Apple's documentation for this article, though, **the "Attestation Object Validation Guide" now includes a real attestation sample** (Team ID `1234567890`, bundle ID `com.example.myapp`, challenge `example_server_challenge`). So I fed it straight into MemoryHack's `verify_attestation`:

| What I tried | Result |
|---|---|
| Verify at today's time (2026-09-25) | `chain_expired`. The leaf certificate is valid for **only three days: 2026-04-20 18:13:12 UTC to 2026-04-23 18:13:12 UTC** |
| Pin `now` to 2026-04-21 | The chain **validates up to the real, pinned Apple root**, then fails with `nonce_mismatch` |
| Inspect the nonce | The nonce in the certificate is `SHA256(authData ‖ challenge)`: the sample passed the **raw, unhashed** challenge as `clientDataHash`. That contradicts the guide's own prose ("the SHA256 hash of the one-time challenge") |
| Match only the challenge hashing to the sample | Every remaining check (key ID, App ID, counter, aaguid, credentialId) passes, and the key is accepted with `counter=0` |

Three practical lessons come out of this.

1. **How you build `clientDataHash` is a contract between your client and your server, not something Apple fixes.** App Attest feeds whatever 32 bytes you hand it into the nonce. MemoryHack's Swift code passes `SHA256.hash(data: challenge)` and the server computes the same thing. Change one side only and every proof fails with `nonce_mismatch`.
2. **The official sample can serve as a regression fixture, as long as you pin the clock.** Because `verify_attestation` takes `now` as a parameter, passing a time inside the sample's validity window lets a test exercise the **real Apple root** instead of a synthetic chain. (The MemoryHack repository does not include such a test yet.)
3. **Don't trust every number in the guide.** Its "expected public key SHA256 hash" (`inGjK2…`) did not match anything I computed: neither the SHA-256 of the X9.62 uncompressed point nor that of the DER SubjectPublicKeyInfo. What matched the key ID (`zgSY9Y…`) and `credentialId` was the X9.62 hash, computed exactly as step 5's prose describes.

Apple's pages also disagree with each other. The validation page gives the development `aaguid` as `appattestdevelop`, while "Preparing to use the App Attest service" calls it `appattestsandbox`; both are 16 bytes. MemoryHack follows the former and never accepts a development value in production. The reliable move is to **save one real attestation from a development build and check its `aaguid` yourself.**

## 5. App Attest: verifying assertions and the counter

Once a key is registered, every metered POST carries an assertion (`{signature, authenticatorData}`). Apple's steps: (1) hash `clientData`, (2) take the SHA-256 of `authenticatorData ‖ clientDataHash` as the nonce, (3) verify the signature with the stored public key, (4) check the RP ID, (5) confirm the counter is greater than last time, (6) confirm the challenge embedded in `clientData` is one you issued.

```python
# backend/services/infra/app_attest_service.py (excerpt)
def verify_assertion(*, public_key_der, assertion, challenge, team_id, bundle_id,
                     stored_counter) -> int:
    obj = _decode_map(assertion, "assertion_not_cbor")
    signature = _field(obj, "signature", bytes, "assertion_signature_type")
    auth_data = _field(obj, "authenticatorData", bytes, "assertion_auth_data_type")

    _require(len(auth_data) >= 37, "assertion_auth_data_short")
    expected_rp_id = hashlib.sha256(app_id(team_id, bundle_id).encode()).digest()
    _require(auth_data[0:32] == expected_rp_id, "assertion_app_id_mismatch")

    counter = int.from_bytes(auth_data[33:37], "big")
    _require(counter > stored_counter, "assertion_counter_replay")

    public_key = _load_public_key(public_key_der)
    nonce = hashlib.sha256(auth_data + hashlib.sha256(challenge).digest()).digest()
    try:
        public_key.verify(signature, nonce, ec.ECDSA(hashes.SHA256()))
    except InvalidSignature as exc:
        raise AppAttestError("assertion_bad_signature") from exc
    return counter
```

The part that matters most is **the write that stores the counter after verification succeeds**. Read, compare and write as separate steps, and two Lambdas that receive the same assertion at once will both see "greater than last time" and both let it through. So the comparison lives inside the condition:

```python
# backend/services/infra/app_attest_store.py (excerpt)
await asyncio.to_thread(
    table.update_item,
    Key=_key_record_key(device_id),                    # PK=DEVICE#{id}, SK=ATTEST_KEY
    UpdateExpression="SET #c = :new, #w = :w",
    ConditionExpression="#k = :k AND #c < :new",       # same key that verified, strictly increasing
    ExpressionAttributeNames={"#c": "counter", "#k": "key_id", "#w": "counter_write_id"},
    ExpressionAttributeValues={":new": counter, ":k": b64(key_id), ":w": token},
    ReturnValuesOnConditionCheckFailure="ALL_OLD",
)
```

The `#k = :k` clause exists because of key rebinding (next section). Between verifying an assertion and writing its counter, the device ID's key can be replaced. If the old key's counter landed on the new key's row, every assertion from the new key would be refused until its own counter climbed past that value.

### A deliberate trade-off: only the challenge is signed

Apple describes `clientData` as the request itself, packaged, with the challenge embedded in it. MemoryHack signs **only the challenge** (the Swift side passes `SHA256(challenge)`), so the request body is not covered by the signature.

That still stops replay of a stolen assertion: the challenge is single-use and travels over TLS. What it does not stop is someone producing signatures on a genuine device and attaching them to request bodies of their own choosing. But an attacker who can do that already controls a genuine device. MemoryHack's gate protects "one free generation", and the body (which photo to turn into cards) is worth nothing to an attacker, so the trade-off holds here. **For an API whose body carries value, like a payment amount, include a hash of the body in `clientData`.**

## 6. Storing keys and rebinding them: plan for reinstalls

Apple's documentation says App Attest keys survive app updates but **not a reinstall, a device migration or a restore from backup**. The Keychain item holding the device ID, however, survives all three. So "the device ID is alive but its key is dead" is a normal, everyday state for ordinary users.

The first implementation bound a key to a device ID exactly once (`attribute_not_exists`). Under `enforce`, that **locks a reinstalling user out for good**. Now a freshly verified attestation replaces whatever key was bound:

```python
# backend/services/infra/app_attest_store.py (excerpt)
UpdateExpression=(
    "SET #replaced = if_not_exists(#kid, :none), #kid = :kid, #pub = :pub, "
    "#c = :c, #at = :at, #w = :w"
),
```

The right-hand side of a `SET` reads the item as it was before the update, so `#replaced` receives **the key ID being replaced**. From that the store reports `BOUND` (first binding), `RE_REGISTERED` (a resend of the same key) or `REBOUND` (a different key), and `REBOUND` is logged at WARNING.

Allowing rebinds is safe because every rebind still requires **a challenge issued for this device ID** and **an attestation that chains to Apple's root**. It cannot be forged or replayed. What remains is that someone with a genuine device who knows another guest's device ID could move that guest's quota gate onto their own device. But the device ID is the guest's bearer credential: whoever holds it can already read and edit that guest's data.

On the client, `frontend/services/auth/app-attest.ts` re-creates the key on two triggers: (1) signing fails with iOS's `invalidKey` (`DCError.invalidKey`), and (2) a signed request is refused by the server with a 403. Both run **at most once per process**, and the stored key ID is overwritten only after the server has bound the new key. Apple asks apps to limit key generation to reinstalls and new users, because a low key count per device makes some kinds of fraud easier to spot; regenerating unconditionally would work against that.

Apple also recommends making sure a public key is not already associated with another user. MemoryHack has no explicit uniqueness check for that; it is listed under section 12.

## 7. Play Integrity: tie the token to the device ID with requestHash

On Android, MemoryHack uses Play Integrity's **standard request**. Google's documented flow: (1) prepare (warm up) the token provider, (2) pass a hash of the action you want to protect as `requestHash` and receive a token, (3) have the server **decrypt the token on Google's servers** and read the verdict.

### 7-1. What goes into requestHash

```python
# backend/services/infra/play_integrity_service.py (excerpt)
def expected_request_hash(device_id: str, challenge_b64: str) -> str:
    # Hex SHA-256 over the device ID, a newline, and the challenge exactly as the
    # server sent it (base64): 64 characters, well under Google's 500-byte limit.
    return hashlib.sha256(f"{device_id}\n{challenge_b64}".encode()).hexdigest()
```

The app (`frontend/services/auth/play-integrity.ts`) hashes the same string with `expo-crypto`. Google caps `requestHash` at 500 bytes and says **never to put sensitive information into it as plain text**; hash everything. With the device ID and the challenge inside, a token minted for one ID is useless for any other, and a reused one runs into an already-spent challenge. Standard requests also have Google's own replay protection: decrypting the same token repeatedly returns an empty device verdict and sets the app verdict to `UNEVALUATED`. MemoryHack does not rely on that alone; its own challenge stops replays too.

### 7-2. Checking the verdict

From the payload `decodeIntegrityToken` returns, these fields are checked in order. Any one missing means REJECTED.

| Field | Expected | Failure reason |
|---|---|---|
| `requestDetails.requestPackageName` | Our package name | `request_package_mismatch` |
| `requestDetails.requestHash` | `expected_request_hash(device_id, challenge)` | `request_hash_missing` / `request_hash_mismatch` |
| `requestDetails.timestampMillis` | Within 300 s plus 60 s of clock skew | `stale_token` |
| `appIntegrity.appRecognitionVerdict` | `PLAY_RECOGNIZED` | `app_not_recognized` |
| `appIntegrity.packageName` | Our package name | `app_package_mismatch` |
| `appIntegrity.certificateSha256Digest` | One of the allowed signing certificates | `certificate_not_allowed` |
| `deviceIntegrity.deviceRecognitionVerdict` | Contains `MEETS_DEVICE_INTEGRITY` | `device_integrity_not_met` |

Google describes `MEETS_DEVICE_INTEGRITY` as a genuine, certified Android device and adds that on Android 13 and higher there is hardware-backed proof that the bootloader is locked and the OS is a certified manufacturer image. An empty verdict means signs of attack (API hooking), system compromise (root), or a non-physical device such as an emulator that does not pass Google Play integrity checks.

Every field in the payload model is optional, because Google omits whatever a token did not carry (a spent token decodes with its verdicts cleared, and a classic token carries `nonce` instead of `requestHash`). **A missing field must be read as a verdict that does not vouch (REJECTED), never as a response we could not parse (UNAVAILABLE).** UNAVAILABLE lets the request through, so if the contents of a token could trigger it, that would be a way around the check.

### 7-3. Call order: challenge → our credentials → budget → decode

```python
# backend/services/infra/play_integrity_service.py (excerpt)
if len(token) > _MAX_TOKEN_CHARS:          # over 16,384 chars: refused before costing anything
    return AttestationOutcome.REJECTED
if not await consume_challenge(device_id, challenge):
    return AttestationOutcome.REJECTED
try:
    access_token = await _access_token()   # settle our own credentials first
    if not await spend_decode_budget(device_id):
        return AttestationOutcome.REJECTED # this device ID's budget for today is gone
    payload = await _decode(token, access_token)
except PlayIntegrityStoreError:
    return AttestationOutcome.UNAVAILABLE
except PlayIntegrityUnavailableError:
    return AttestationOutcome.UNAVAILABLE
```

The order protects Google's quota. Decodes have a **default quota of 10,000 per day**, per Cloud project, shared between classic and standard requests (the usage-limits table on the setup page). A junk token still costs a decode once you send it (the implementation is designed on that assumption). Without a guard, one device ID sending junk tokens could stop verification for every Android guest for the rest of the day.

So each device ID gets **a budget of 30 decodes per day** (`DAILY_DECODE_BUDGET = 30`). The code comment shows the arithmetic: a free guest makes at most 14 metered calls on its first day and 9 on any later day, so 30 leaves more than double the room and absorbs client retries. At 10,000 / 30, exhausting the quota takes about 334 device IDs (`backend/services/infra/play_integrity_store.py`).

The budget is spent only **after an access token has been obtained with our own service-account key**. If our key were revoked, that request would never reach Google, yet the device would lose a unit of budget; our own misconfiguration would turn into "refused for the rest of the day" for that ID.

The access token comes from the JWT bearer flow: a JWT signed with the service-account key is sent to `https://oauth2.googleapis.com/token` (Google, "OAuth 2.0 for Server to Server Applications"). It is cached in the Lambda container until 60 seconds before it expires. A 400, 401 or 403 from the token endpoint is treated as a revoked key: the cached key is dropped and Secrets Manager is not asked again for a minute. A rotated key is then picked up automatically, and until it is, Secrets Manager is not hit on every request.

## 8. Failure modes: what fails open and what fails closed

The hardest part of device attestation is not the cryptography but **what to do when something fails**. MemoryHack reduces each provider's answer to three values:

- `GENUINE`: cryptographically confirmed as a genuine device
- `REJECTED`: not genuine (forged, missing or already spent)
- `UNAVAILABLE`: a transient failure; the gate **lets the request through** (fails open)

A provider that is **switched off returns `None` (it abstains)** and is left out of the merge. Treating abstention as `UNAVAILABLE` would count the disabled provider as "down" and let every request through: an `enforce` mode that enforces nothing. That happened. On an environment without DeviceCheck configured, any request carrying a token came back `UNAVAILABLE`, so sending `X-Device-Check-Token: x` was enough to pass `enforce` (recorded under DEF-6; fixed).

| Situation | Outcome | Why |
|---|---|---|
| Transient DynamoDB failure (challenge or key read/write) | `UNAVAILABLE` → allow | The attacker cannot cause it; don't punish users |
| No key bound to the device ID and no assertion sent | App Attest defers to Play Integrity | An Android install and a forged ID look exactly like this |
| No key bound but an assertion sent anyway | `REJECTED` | Forgery |
| Apple's `attestKey` returns `serverUnavailable` | The app sends the request without a proof | Apple says to retry later with the same key |
| Play Integrity decode quota exhausted (429) | `UNAVAILABLE`, **turned into `REJECTED` when merged** | The attacker can cause it |
| Our service-account key revoked or unreadable | Same (`REJECTED`), plus dedicated log lines and alarms | Android guests are refused meanwhile; operators must find out |
| Simulator, or no App Attest support | The app sends no proof; the server's mode decides | Per Apple, `isSupported` is `false` for apps running on a Mac, including iOS apps on Apple silicon Macs |
| Paying user | Not gated | Nobody loses what they paid for because their device cannot attest |

The merge rule lives in `verify_guest_attestation`. **When a provider that can prove the tie to the ID (App Attest or Play Integrity) gives a verdict, that is the answer. If one was asked and did not vouch, the answer is `REJECTED`.** DeviceCheck is consulted only when nothing that binds could answer at all.

```python
# backend/services/infra/attestation_service.py (excerpt)
app_attest = await _verify_app_attest(proof)
if app_attest is AttestationOutcome.GENUINE or app_attest is AttestationOutcome.REJECTED:
    return app_attest

play = await _verify_play_integrity(proof)
if play is AttestationOutcome.GENUINE:
    return play
if play is not None:
    # Asked, so it decides, and only GENUINE passes. UNAVAILABLE is caller-reachable.
    if play is AttestationOutcome.UNAVAILABLE:
        logger.warning("[METRIC] attestation_binding_unavailable provider=play_integrity")
    return AttestationOutcome.REJECTED
```

### Staged rollout: off → monitor → enforce

The gate has three modes. In `monitor` it verifies but never refuses, and logs what `enforce` *would* have blocked, one line per evaluation:

```text
[METRIC] attestation_check decision=would_block outcome=rejected route=generation mode=monitor proof=none app_version=… device=…xxxxxx
```

Four CloudWatch metric filters count these lines (check, would_block, block, unavailable). The patterns match on `logger = "core.quota"` plus a prefix of `message`. Matching bare words would let anyone forge or dilute the alarms by putting the same words into the request path or the `X-Request-Id` header, both of which the client controls. The criteria for moving to `enforce` are in the runbook (`docs/ops/SECURITY_OPS.md` §5.5), for example "at least 7 days in `monitor` on production, with at least 200 checks on that route".

Apple also asks for a gradual App Attest rollout, because many installs calling `attestKey` at once can be throttled. Its rule of thumb is to ramp up no more than 10 million users per day per app and keep `attestKey` under 100 requests per second across all installs.

## 9. Capping AI spend: a per-user quota is not a spending limit

Perfect device attestation still leaves the bill uncapped. The comment at the top of `spend_guard.py` gives three reasons:

1. **IDs are free to mint.** Even with App Attest, the number of keys one device creates cannot be limited.
2. **A bug can detach the counter from the spend.** MemoryHack had one: when the guard refused an AI tutor answer, the allowance was refunded, so a caller could buy unlimited billed Gemini calls while the counter never moved (fixed on 2026-09-17; the comment records it as reproduced 8 times out of 8).
3. **The AWS budget alarm cannot see this bill**, because Google bills for Gemini.

So an environment-wide daily ceiling sits in front of **the single function** through which the app talks to an LLM (`ai_service.generate_recorded`):

```python
# backend/services/ai/spend_guard.py (excerpt)
async def reserve_ai_call(*, quota_type: str, now=None) -> AIBudgetReservation | None:
    limit = settings.ai_daily_call_budget           # per-environment daily ceiling (JST day)
    if limit <= 0 or quota_type in PAID_QUOTA_TYPES:
        return None                                 # paid pools are not metered here
    bucket = jst_bucket(now or datetime.now(tz=UTC))
    response = await asyncio.to_thread(
        get_main_table().update_item,
        Key=ai_budget_key(bucket),
        UpdateExpression="ADD #count :inc SET #ttl = :ttl, #write = :write",
        ConditionExpression=RESERVE_ONCE_CONDITION, # (#count missing OR #count < :limit) AND not our own resend
        ...,
        ReturnValues="ALL_NEW",
        ReturnValuesOnConditionCheckFailure="ALL_OLD",
    )
    spent = dynamo_int(response["Attributes"]["calls"])
    if spent >= max(1, int(limit * 0.8)) and not response["Attributes"].get("warned"):
        await _warn_once(bucket, spent=spent, limit=limit)   # the day's single warning
    return AIBudgetReservation(bucket)
```

What matters in this design:

- **Only calls nobody paid for are counted.** Paid pools are covered by subscription revenue and are exempt. Counting them would let whoever mints free IDs spend the ceiling and **lock paying users out of AI**. Free IDs cost nothing to mint, so no ceiling size would prevent that.
- **The unit is calls, not tokens.** The limit has to be checked *before* the call, when the token count is unknown. Output is capped per feature, so each call has a bounded worst-case cost.
- **Calls the provider never answered are given back.** A rate-limited, unavailable or timed-out call was not billed, so its unit is released. On one measured development day, 21 of 45 attempts were provider failures (recorded in the comment). Without releasing them, retries during a provider incident spend the ceiling, and the feature stays dark after the provider recovers.
- **A DynamoDB failure fails closed**, the opposite of the gate's `UNAVAILABLE`. By the time this runs, the per-user reservation on the same table has already succeeded, so failing closed adds no new failure mode.
- **The 80% warning fires once per day.** Only the request that wins a conditional write on the `warned` flag logs it. The counter can also go down as units are released, so testing for "exactly 80%" would fire again every time it crossed the line.

Two alarms are defined in Terraform (`infra/main.tf`):

| Alarm | Trigger | Period | Meaning |
|---|---|---|---|
| `ai-budget-warning` | a `[METRIC] ai_budget_warning` line | 300 s, threshold > 0 | "Decide now whether to raise the ceiling" |
| `ai-budget-exhausted` | a `[METRIC] ai_budget_exhausted` line | 60 s, threshold > 0 | "Refusals are already happening"; the first refusal pages |

They are separate because they call for different responses: one is a decision, the other is an incident already in progress.

For the web-side view of protecting AI spend (edge rate limiting and Turnstile), see [protecting a sign-in-free AI chat from bill-shock attacks](/blog/anonymous-ai-chat-cost-dos-defense-edge-rate-limiting-turnstile-guide). On the web, that setup does most of the work; in a mobile app, the device attestation in this article comes on top of it.

## 10. Idempotency: don't mistake an SDK retry for a replay

The bugs in this code came less from cryptography than from **retries**.

Boto3's standard retry mode makes up to 3 attempts by default (the first included) and retries on errors such as `RequestTimeout`, connection errors and HTTP 500, 502, 503 and 504 (Boto3, "Retries"). MemoryHack sets a 3-second read timeout (`READ_TIMEOUT_SECONDS` in `memoryhack_shared/dynamo.py`). When a write commits in DynamoDB but the response is lost, the SDK sends the same write again, and the second send is evaluated against the row the first one already changed.

That produced bugs like these (each has a fix in the commit history):

- When spending a challenge was a **delete**, the resend found no row, reported the challenge the call had just spent as unspendable, and a genuine device was refused.
- The counter update's resend was refused by `#c < :new`, so the call **read its own assertion as a replay**.
- The daily ceiling counted one call twice.

The fix has the same shape everywhere: **mint a token per call, write it into the row, and when the condition fails, look at the row returned by `ReturnValuesOnConditionCheckFailure="ALL_OLD"`.** If it carries your token, your first send landed.

```python
# packages/memoryhack_shared/src/memoryhack_shared/dynamo.py (excerpt)
def own_write_row(exc, *, attribute: str, token: str):
    if not isinstance(exc, ClientError) or not is_conditional_check_failed(exc):
        return None
    row = exc.response.get("Item")        # the ALL_OLD row, in DynamoDB wire format
    if not isinstance(row, Mapping):
        return None
    stored = row.get(attribute)
    if not isinstance(stored, Mapping) or stored.get("S") != token:
        return None
    return row                            # this call's own first send wrote this row
```

`ReturnValuesOnConditionCheckFailure` returns the item's attributes for an `UpdateItem` that failed its condition check, and the AWS API reference states that it consumes no read capacity units.

`counter_resend.py` also explains why `TransactWriteItems` with a `ClientRequestToken` (which turns a resend within ten minutes into a no-op) was not used. A transaction makes plain writes that overlap it on the same row fail with `TransactionConflictException`, which is not in boto3's list of retried errors. The counter rows are written by plain `UpdateItem`s from reservations, refunds and the AI tutor's allowance, so moving to transactions would trade a rare double count for 503s and lost refunds under everyday concurrency.

## 11. Testing: what gets checked where

### 11-1. Cryptography: a synthetic chain with the real structure

A real attestation is bound to a live Secure Enclave key and a challenge, and it expires. So the unit tests build **a chain with Apple's structure (P-384 root → P-384 intermediate → P-256 leaf, with the nonce extension) under a synthetic root** and swap the pinned anchor. Signatures are really verified and nonces really recomputed. Each test breaks exactly one condition and requires verification to fail:

- a challenge we did not issue, another app's App ID, a key ID that is not the hash of the key, a non-zero counter, an expired certificate, a chain of the wrong length, a non-EC key, a certificate with no signature hash algorithm, a missing nonce extension, malformed CBOR
- a replayed assertion (same counter), a signature from another key, a different challenge, another app
- whether the pinned root really is Apple's (`test_pinned_root_is_apples`)

Running the eight relevant files locally (App Attest verification and storage, the provider merge, Play Integrity verification and budget, the router, the metric filters and the daily ceiling) gave **293 passed** (`pytest -p no:cacheprovider --no-cov` over the eight files; each parametrized case counts as one).

The metric-filter test (`tests/core/test_attestation_metric_filters.py`) replays every real log line through the patterns defined in Terraform, so reordering the fields of a log line cannot silently mute an alarm.

### 11-2. Conditional writes: DynamoDB Local, not moto

`moto` cannot prove a conditional write correct: it does not validate condition-expression syntax, so it accepts expressions real DynamoDB rejects. MemoryHack keeps **harnesses that run against DynamoDB Local** in `scripts/verify/` (34 of them per `ls scripts/verify/*_ddb_check.py`). Four are relevant here:

| Harness | What it checks |
|---|---|
| `app_attest_challenge_ddb_check.py` | Single-use challenge spend, expiry down to the last second, another device's challenge, **an 8-way race**, and resends of the counter advance and the key rebind |
| `play_integrity_budget_ddb_check.py` | The budget runs out at exactly 30, never exceeds its ceiling under 8 concurrent callers, and is independent per device ID |
| `ai_spend_guard_ddb_check.py` | The daily ceiling holds exactly under an 8-way race, a resent reserve or release counts once, and the 80% warning writes one line even when its claim is resent |
| `counter_resend_ddb_check.py` | Resent reserves and releases on the per-user allowances, and races right at the ceiling |

Resends are reproduced with **boto3's own retry machinery**:

```python
# scripts/verify/_sent_twice.py (excerpt)
class EveryUpdateSentTwice:
    """Make botocore resend every UpdateItem once, after the first send has landed."""

    def _resend_the_first(self, attempts: int, **_: object) -> int | None:
        if attempts != 1:
            return None
        self.resent += 1
        return 0          # answer botocore's needs-retry hook with "retry after 0 s"

    def __enter__(self):
        self._events.register_first("needs-retry.dynamodb.UpdateItem", self._resend_the_first)
        return self
```

Many harnesses also include **a control**: the pre-fix write, resent the same way, visibly double-counts or refuses itself. That shows not just that the fix passes, but that the harness can tell a broken implementation apart. Some also send a deliberately broken expression, such as an unbalanced parenthesis, to confirm DynamoDB Local rejects it. The harnesses are not in CI, so a container that fails to start cannot turn CI red; they run by hand with `make ci-ddb-local`.

### 11-3. Keeping client and server in step

Only metered POSTs are gated, so the app keeps an allowlist of paths that carry a proof (`frontend/services/http/attested-routes.ts`). `frontend/__tests__/architecture/attested-routes-match-backend.test.ts` reads the backend routers' source and fails if a metered POST is missing from the list. Before it existed, the AI tutor path (which includes a deck ID and a card ID) was stored as a literal string that never matched a real URL, so switching to `enforce` would have refused every free guest's tutor call (recorded under DEF-6). Paths are now matched segment by segment with an `:id` placeholder, and this test catches omissions.

## 12. What is not implemented, and the limits

| Item | Status | Consequence |
|---|---|---|
| `enforce` in production | Not applied (DEF-6) | Production does not refuse forged IDs yet. `monitor` can measure how many there would be |
| App Attest receipt and fraud-risk metric (`attestationData` endpoint) | Not used | No way to ask Apple how many keys one device has created |
| Per-device cap on IDs (DeviceCheck's two bits, Play Integrity device recall) | Not wired | Clearing app data mints a new ID on the same device; layer ③'s daily ceiling is the last line |
| Newer Apple verification steps (the `apple_validation_category_01` / `apple_bundle_version_01` extensions, the macOS `aclBlob`) | Not verified | Listed as steps in the documentation as of September 2026. Whether an iOS app needs them should be decided against a real attestation |
| Checking that a public key is not bound to another ID | No explicit check | An additional measure Apple recommends |
| Signing the request body | Not done (challenge only) | Unsuitable for APIs whose body carries value (section 5) |
| Android distribution | Internal testing only; not on Google Play | The Play Integrity path has no production track record yet |

Apple itself acknowledges in "Assessing fraud risk" that an attacker who modifies the device's operating system might bypass these restrictions, and offers the fraud-risk metric as a guard against one compromised device serving assertions to many compromised copies of an app. Device attestation raises the cost of abuse; it does not make abuse impossible. That is why layer ③ exists.

## The app this code runs in

The code in this article comes from the backend of [MemoryHack, an AI app that turns photos of your study material into flashcards](https://apps.apple.com/app/id6759282044?pt=126890987&ct=tomodahinata_blog_en&mt=8) (AWS Lambda and DynamoDB, managed with Terraform; the app's interface is in Japanese). The rest of the design, including the asynchronous photo-to-cards pipeline and idempotent offline review, is in the [MemoryHack AI write-up](/labs/memoryhack-ai). For mobile app security as a whole (token storage, transport protection, tamper resistance), organized by OWASP MASVS control group, see the [mobile app security implementation guide](/blog/mobile-app-security-owasp-masvs-secure-storage-guide).

If you are building a mobile app with generative AI and want to settle, at design time, how to keep scripts from eating your free tier, which way to fail when a provider is down, and how to put a hard cap on the bill, I take on [development work](/services) of this kind.
