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.13runtime) withcryptography50.0.0,cbor25.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,monitorandenforce, and production has not been moved toenforce(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 runsofformonitor. 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.
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:
| 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"):
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_atis 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 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:
# 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.
- How you build
clientDataHashis 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 passesSHA256.hash(data: challenge)and the server computes the same thing. Change one side only and every proof fails withnonce_mismatch. - The official sample can serve as a regression fixture, as long as you pin the clock. Because
verify_attestationtakesnowas 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.) - 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…) andcredentialIdwas 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.
| 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
# 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 deviceREJECTED: 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.
# 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:
- IDs are free to mint. Even with App Attest, the number of keys one device creates cannot be limited.
- 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).
- 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
warnedflag 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. 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:
| 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:
# 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 (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.