Skip to main content
モバイルアプリ開発(Expo / React Native)
セキュリティ
iOS
Android
Python
AWS
AI
コスト最適化
信頼性
テスト

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
Reading time
31 min read
Author
友田 陽大
Share

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, 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.) 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. 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

LayerWhat it establishesWhat it cannot establishWhere 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 IDHow many IDs one device createsservices/infra/attestation_service.py
② Per-user quotaHow many calls one ID may make per dayHow many IDs get createdcore/quota.py
③ Environment-wide daily ceilingThe most LLM spend you can incur in a dayWho spent itservices/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.

I can take on the implementation from this article as an engagement

Abuse and runaway-cost defense for generative AI endpoints — design, implementation, and monitoring

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:

MechanismCan an attacker produce it locally?Tied to the device ID?Notes
API key embedded in the appYes (it can be extracted)NoNot a defense
Device-generated UUIDYes—An identifier, not a proof
Apple DeviceCheck tokenNo (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 AttestNo (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"):

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

# 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

# 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 stepCheckFailure reason in code
1x5c holds the leaf and intermediate; validate up to the App Attest rootchain_length / chain_bad_signature / chain_expired
2–3clientDataHash = SHA256(challenge), nonce = SHA256(authData ‖ clientDataHash)—
4The OCTET STRING in the leaf's extension OID 1.2.840.113635.100.8.2 equals noncenonce_mismatch / nonce_ext_*
5SHA-256 of the leaf's public key (X9.62 uncompressed point) equals the key IDkey_id_mismatch
6SHA256("<TeamID>.<BundleID>") equals the RP ID in authDataapp_id_mismatch
7counter is 0counter_not_zero
8aaguid is appattest + seven 0x00 bytes in production, appattestdevelop in developmentaaguid_mismatch
9credentialId equals the key IDcred_id_mismatch

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

# 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 triedResult
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-21The chain validates up to the real, pinned Apple root, then fails with nonce_mismatch
Inspect the nonceThe 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 sampleEvery 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.

# 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:

# 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:

# 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

# 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.

FieldExpectedFailure reason
requestDetails.requestPackageNameOur package namerequest_package_mismatch
requestDetails.requestHashexpected_request_hash(device_id, challenge)request_hash_missing / request_hash_mismatch
requestDetails.timestampMillisWithin 300 s plus 60 s of clock skewstale_token
appIntegrity.appRecognitionVerdictPLAY_RECOGNIZEDapp_not_recognized
appIntegrity.packageNameOur package nameapp_package_mismatch
appIntegrity.certificateSha256DigestOne of the allowed signing certificatescertificate_not_allowed
deviceIntegrity.deviceRecognitionVerdictContains MEETS_DEVICE_INTEGRITYdevice_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

# 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).

SituationOutcomeWhy
Transient DynamoDB failure (challenge or key read/write)UNAVAILABLE → allowThe attacker cannot cause it; don't punish users
No key bound to the device ID and no assertion sentApp Attest defers to Play IntegrityAn Android install and a forged ID look exactly like this
No key bound but an assertion sent anywayREJECTEDForgery
Apple's attestKey returns serverUnavailableThe app sends the request without a proofApple says to retry later with the same key
Play Integrity decode quota exhausted (429)UNAVAILABLE, turned into REJECTED when mergedThe attacker can cause it
Our service-account key revoked or unreadableSame (REJECTED), plus dedicated log lines and alarmsAndroid guests are refused meanwhile; operators must find out
Simulator, or no App Attest supportThe app sends no proof; the server's mode decidesPer Apple, isSupported is false for apps running on a Mac, including iOS apps on Apple silicon Macs
Paying userNot gatedNobody 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.

# 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:

[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):

# 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):

AlarmTriggerPeriodMeaning
ai-budget-warninga [METRIC] ai_budget_warning line300 s, threshold > 0"Decide now whether to raise the ceiling"
ai-budget-exhausteda [METRIC] ai_budget_exhausted line60 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. 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.

# 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 UpdateItems 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:

HarnessWhat it checks
app_attest_challenge_ddb_check.pySingle-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.pyThe 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.pyThe 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.pyResent reserves and releases on the per-user allowances, and races right at the ceiling

Resends are reproduced with boto3's own retry machinery:

# 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

ItemStatusConsequence
enforce in productionNot 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 usedNo 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 wiredClearing 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 verifiedListed 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 IDNo explicit checkAn additional measure Apple recommends
Signing the request bodyNot done (challenge only)Unsuitable for APIs whose body carries value (section 5)
Android distributionInternal testing only; not on Google PlayThe 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 (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. 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.

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 of this kind.

Frequently asked questions

If I embed an API key in my app, can I stop calls that don't come from my app?
No. The binary you ship sits on the attacker's machine, so the key can be pulled out by decompiling the app or by intercepting its traffic. Once extracted, it works just as well from a script. Keep the LLM key on the server, and have the server verify that a call comes from a genuine install of your genuine app before letting it through. App Attest does that on iOS and Play Integrity does it on Android.
Which App Attest server-side steps are easiest to get wrong?
Three stand out. First, mark the challenge as spent before verifying anything; if you do it afterwards, one challenge can be tried again and again. Second, pin Apple's root certificate as a file instead of fetching it over the network. Third, store the assertion counter with a conditional write that only succeeds when the new value is greater than the stored one. A read-then-write lets the same assertion through twice when two copies arrive at once.
Can my server decrypt Play Integrity tokens itself?
Not for standard requests. Google's documentation says to decrypt them on Google's servers: get an access token for the playintegrity scope from a service account and call decodeIntegrityToken on playintegrity.googleapis.com. Decodes count against a default quota of 10,000 per day, shared between classic and standard requests.
When Apple or Google is down, should requests be allowed or blocked?
It depends on who can cause the failure. A transient failure the attacker cannot trigger, like a brief DynamoDB error on your side, should fail open. Running out of Play Integrity decode quota is different: anyone can cause it by sending junk tokens. If that failure fails open, exhausting the quota switches the whole check off, so it has to fail closed. The implementation in this article pins that distinction in code and tests.
What happens on simulators, emulators and devices without App Attest?
They cannot produce a proof, so the app sends the request without one; it does not raise an error on the client. The server's mode (off, monitor or enforce) decides what that is worth. In this implementation the gate applies only to the free guest tier; paying users are never locked out because their device cannot attest.
Does device attestation keep my AI bill under control?
Not by itself. App Attest proves that a call comes from a genuine install, but it does not limit how many keys one device can create, and a bug can make the usage counter drift away from the calls you actually pay for. So put an environment-wide daily ceiling in front of the single function that calls the LLM, and alarm at 80% and at exhaustion.

References

友田

友田 陽大

Developer of a METI Minister's Award–winning product. With TypeScript + Python + AWS, I deliver SaaS, industry DX, and production-grade generative AI (RAG) end to end — from requirements to infrastructure and operations — single-handedly.

I can take on the implementation from this article as an engagement

Abuse and runaway-cost defense for generative AI endpoints — design, implementation, and monitoring

I implement the layered defense that lets you ship a login-free AI chat or a public LLM feature without breaking your invoice: volumetric control at the WAF, two-tier rate limiting that keeps the network and session identifiers independent, bot exclusion via Turnstile or BotID, a hard budget ceiling on the provider side, and monitoring that watches spend rather than error rate. Having run an unattended, login-free generative AI voice concierge in production — where you cannot control who walks up and talks to it — I design the consumption boundary first, so the thing stays up, stays cheap, and stays safe.

Available for both project-based (contract) and advisory engagements. Start with a free 30-minute consult.

Also worth reading