--- name: axiom-app-attest description: Use when implementing app integrity verification, preventing fraud with DCAppAttestService, validating requests from legitimate app instances, using DeviceCheck for promotional abuse prevention, or needing server-side attestation/assertion validation. Covers key generation, attestation, assertion, rollout strategy, and risk metrics. license: MIT --- # App Attest Device-backed app integrity verification for fraud prevention. Proves three things to your server: the request came from a genuine Apple device, running your genuine app, with an untampered payload. ## When to Use This Skill Use when you need to: - Verify requests come from legitimate app instances (not modified/cloned apps) - Prevent fraud in purchases, promotions, or competitive features - Implement DCAppAttestService attestation or assertion flows - Handle DeviceCheck 2-bit per-device state for promotional abuse - Build server-side validation for attestation objects or assertion signatures - Plan a gradual App Attest rollout for a large install base ## Example Prompts "How do I verify my app hasn't been tampered with?" "DCAppAttestService attestKey keeps failing with serverUnavailable" "How do I prevent users from claiming a free trial multiple times?" "What's the difference between attestation and assertion?" "How do I validate an attestation object on my server?" "isSupported returns false — should I block the user?" "We have 2M DAU, how do I roll out App Attest safely?" "How do I detect if someone is creating fake app instances?" ## Red Flags Signs you're headed for trouble: - **Validating app integrity on-device** — Modified apps control the runtime. Any local check can be patched out. Verification MUST happen server-side. - **Not guarding with isSupported** — DCAppAttestService crashes on unsupported devices. Always check before calling any API. - **Blocking users when isSupported returns false** — Some legitimate devices return false. Treat as risk signal, not hard block. - **Reusing keys across multiple users on same device** — One key per user per device. Shared keys break account-level trust association. - **Enabling App Attest for all users at once** — `attestKey` calls Apple's servers. At scale, rate limiting causes failures. Gradual rollout required (WWDC 2021-10244). - **Using assertions for every API call** — Cryptographic cost per call. Reserve for sensitive operations (purchases, account changes), not routine fetches. - **Discarding key on serverUnavailable error** — Transient Apple server issue. Retry with same key. Only discard on other errors. - **Skipping counter validation on server** — Counter must be ever-increasing. Without this, replay attacks succeed. ## Three Properties Verified App Attest proves three things about each request: | Property | What It Proves | How | |----------|---------------|-----| | Genuine device | Request comes from real Apple hardware | Hardware-backed key in Secure Enclave | | Genuine app | Your app binary, unmodified | App identity hash in attestation | | Untampered payload | Request data hasn't been altered | Digest signing in assertions | **Privacy design**: Anonymous. No hardware identifiers. Keys don't survive reinstall/migration/restore. Apple can't correlate across apps or users. ## Key Generation ```swift import DeviceCheck func generateAppAttestKey(for userId: String) async throws -> String { let service = DCAppAttestService.shared guard service.isSupported else { // NOT an error — use as risk signal, not blocker reportUnattestedDevice() throw AppAttestError.unsupported } let keyId = try await service.generateKey() // Cache persistently — one key per user per device UserDefaults.standard.set(keyId, forKey: "appAttestKeyId_\(userId)") return keyId } ``` **Key lifecycle**: One key per user per device. Cache `keyId` persistently (Keychain or UserDefaults). Keys don't survive reinstall, migration, or restore. App Clips share identity with full app. Generate new key on sign-out. ## Attestation Flow Attestation registers the key with Apple and your server. Happens once per key. ```dot digraph attestation { "Server issues\nchallenge" [shape=ellipse]; "SHA256 hash\nchallenge" [shape=box]; "attestKey API\n(Apple servers)" [shape=box]; "Send attestation\nto your server" [shape=box]; "Server validates\ncertificate chain" [shape=box]; "Store public key\n+ key association" [shape=doublecircle]; "Error?" [shape=diamond]; "serverUnavailable?" [shape=diamond]; "Retry same key" [shape=box]; "Discard key\ngenerate new" [shape=box]; "Server issues\nchallenge" -> "SHA256 hash\nchallenge"; "SHA256 hash\nchallenge" -> "attestKey API\n(Apple servers)"; "attestKey API\n(Apple servers)" -> "Error?" ; "Error?" -> "Send attestation\nto your server" [label="success"]; "Error?" -> "serverUnavailable?" [label="error"]; "serverUnavailable?" -> "Retry same key" [label="yes"]; "serverUnavailable?" -> "Discard key\ngenerate new" [label="no"]; "Send attestation\nto your server" -> "Server validates\ncertificate chain"; "Server validates\ncertificate chain" -> "Store public key\n+ key association"; } ``` ```swift func attestKey(userId: String) async throws { guard let keyId = storedKeyId(for: userId) else { throw AppAttestError.noKey } // 1. Get one-time challenge from YOUR server (minimum 16 bytes) let challenge = try await server.fetchAttestationChallenge() // 2. Hash the challenge let hash = Data(SHA256.hash(data: challenge)) // 3. Request attestation from Apple do { let attestation = try await service.attestKey(keyId, clientDataHash: hash) // 4. Send attestation object to YOUR server for validation try await server.verifyAttestation(attestation, keyId: keyId, challenge: challenge) } catch DCError.serverUnavailable { // Transient — retry with SAME key later scheduleAttestationRetry(keyId: keyId, userId: userId) } catch { // Other error — key is compromised or invalid // Discard and generate a new key clearStoredKey(for: userId) try await generateAndAttestNewKey(userId: userId) } } ``` **Challenge requirements**: Server-generated, single-use, minimum 16 bytes, short-lived (expire after minutes, not hours). ## Assertion Flow Assertions prove ongoing request integrity. No Apple server involvement — on-device only. ```swift func assertRequest(payload: Data, userId: String) async throws -> Data { guard let keyId = storedKeyId(for: userId) else { throw AppAttestError.noKey } // Hash the payload you want to protect let hash = Data(SHA256.hash(data: payload)) // Generate assertion (on-device, no network) let assertion = try await service.generateAssertion(keyId, clientDataHash: hash) // Send assertion + original payload to server // Server verifies signature and checks counter return assertion } ``` **When to assert**: Reserve for moments that cost you money or trust if faked. | Assert | Don't Assert | |--------|-------------| | In-app purchases | Content fetches | | Account changes (email, password) | Read-only API calls | | Competitive actions (leaderboard scores) | Analytics events | | Promotional claims (free trial) | UI configuration | | Reward redemptions | Search queries | **Performance**: Secure Enclave operations. Fast enough for individual actions, expensive on every request. ## Server-Side Validation Your server does the actual trust verification. The app only generates cryptographic material. ### Attestation Validation (once per key) 1. **Certificate chain** — Verify roots to Apple's App Attest root CA (Apple Private PKI) 2. **Nonce** — Recompute SHA256(challenge || clientDataHash), match against credential certificate 3. **App identity hash** — SHA256(teamId + "." + bundleId) must match your app 4. **Counter** — Store initial value (assertions increment from here) 5. **Key association** — Extract and store public key, associate with user account ### Assertion Validation (per sensitive request) 1. **Signature** — Verify using stored public key from attestation 2. **App identity hash** — Must match attestation's hash (prevents cross-app replay) 3. **Counter** — Must be strictly greater than last seen value (replay protection) 4. **Client data hash** — Recompute from request payload, must match what was signed **Counter is critical**: Without strictly-increasing counter validation, replay attacks succeed indefinitely. ## Rollout Strategy From WWDC 2021-10244: `attestKey` makes a network call to Apple's servers. Apple rate-limits these calls per app. | Install Base | Recommended Ramp Time | |-------------|----------------------| | <100K DAU | Days | | ~1M DAU | ~1 day gradual ramp | | ~100M DAU | Weeks | | ~1B DAU | 1+ month gradual ramp | ### Gradual Enablement Pattern ```swift func shouldEnableAppAttest(userId: String) -> Bool { guard DCAppAttestService.shared.isSupported else { return false } // Server controls rollout percentage — start at 1%, ramp daily return server.isAppAttestEnabled(for: userId) } ``` **Rollout process**: Start at 1%. Monitor attestation success rate. If above 95%, double daily. If rate limiting errors spike, pause. Treat unattested requests as lower-trust during rollout (additional fraud signals), not blocked. ## DeviceCheck Integration DeviceCheck stores 2 bits of state per device on Apple's servers. Different purpose from App Attest. | Feature | App Attest | DeviceCheck | |---------|-----------|-------------| | Purpose | Verify app integrity | Track per-device state | | Survives reinstall | No | Yes (tied to hardware) | | Apple servers | Attestation only | Every query | ### Promotional Fraud Prevention ```swift import DeviceCheck func checkTrialEligibility() async throws -> Bool { guard DCDevice.current.isSupported else { return true } let token = try await DCDevice.current.generateToken() // Server calls Apple: POST https://api.devicecheck.apple.com/v1/query_two_bits let state = try await server.queryDeviceState(token: token) return !state.bit0 // bit0 = has claimed trial } func markTrialClaimed() async throws { let token = try await DCDevice.current.generateToken() // Server calls Apple: POST https://api.devicecheck.apple.com/v1/update_two_bits try await server.updateDeviceState(token: token, bit0: true) } ``` **2 bits, your rules**: Apple stores bits + timestamp. Semantics are yours (e.g., bit0=trial claimed, bit1=abuse flagged). Reset on your schedule. Shared across all apps from the same developer team — coordinate meaning across your portfolio. ## Risk Metric Service After attestation, redeem the receipt with Apple to get risk metrics: **Server-side**: POST receipt to `https://data.appattest.apple.com/v1/attestationData` (use `data-development.appattest.apple.com` for sandbox). Response includes approximate key count for the device. **How to use**: Most devices have 1-3 keys. High key counts signal an attacker creating many fake identities. Redeem periodically (Apple rate-limits), establish a baseline for your app, and combine with other fraud signals (velocity, behavioral analysis). ## Anti-Rationalization Table | Rationalization | Why It Fails | What To Do Instead | |----------------|-------------|-------------------| | "We'll validate integrity on-device" | Modified apps control the runtime and can patch out any local check | All validation on your server. Device only generates crypto material. | | "isSupported is always true on modern devices" | Some configurations and enterprise MDM setups return false | Always guard. Handle false as risk signal, not crash. | | "One key per device is enough" | Multi-user devices need per-user keys for accurate account association | One key per user per device. New key on sign-out. | | "We'll enable App Attest for everyone on launch day" | Apple rate-limits attestKey calls. Large install bases will see widespread failures. | Server-controlled gradual rollout. Monitor success rate. | | "Assert every API call for maximum security" | Secure Enclave operations have real cost. Assertion latency on every request degrades UX. | Assert sensitive operations only. Use session tokens for routine calls. | | "serverUnavailable means the key is bad" | It's a transient Apple server issue. Discarding the key forces re-attestation unnecessarily. | Retry with same key. Only discard on non-transient errors. | | "We don't need counter validation" | Without strictly-increasing counters, replay attacks succeed indefinitely. | Store counter server-side. Reject assertions with counter <= last seen. | | "DeviceCheck replaces App Attest" | DeviceCheck is 2-bit state storage, not integrity verification. Different threat models. | Use both: App Attest for integrity, DeviceCheck for per-device flags. | ## Pressure Scenarios ### Scenario 1: "Block users who fail attestation" **Pressure**: "If they can't attest, they're probably running a modified app. Block them." **Reality**: `isSupported` returns false on legitimate devices (older hardware, enterprise MDM, simulator). During rollout, most users simply haven't been enrolled yet. Blocking = blocking real customers. **Correct action**: Trust tiers on server. Attested = high trust. Unattested = lower trust with additional fraud signals. Never hard-block on attestation failure alone. **Push-back template**: "Some legitimate devices return isSupported=false. Let's use attestation as one signal in a risk score — high trust for attested, additional checks for unattested." ### Scenario 2: "Enable App Attest for everyone at once" **Pressure**: "We've been building this for weeks. Ship it to everyone." **Reality**: `attestKey` calls Apple's servers. Apple rate-limits per app. At 5M DAU, flipping the switch causes a thundering herd — mass failures, error floods, confused users. WWDC 2021-10244 explicitly recommends gradual rollout. **Correct action**: Server-controlled rollout starting at 1%. At 5M DAU, expect ~1 week to full rollout. **Push-back template**: "Apple rate-limits attestKey calls — their WWDC session recommends gradual rollout. I'll set up server-side percentage control starting at 1%, ramping to 100% over about a week." ## Checklist Before shipping App Attest: **Key Generation**: - [ ] `isSupported` checked before any DCAppAttestService call - [ ] Graceful handling when `isSupported` returns false (risk signal, not block) - [ ] Key ID cached persistently per user - [ ] One key per user per device (not shared) **Attestation**: - [ ] Challenge from server is single-use, minimum 16 bytes, short-lived - [ ] `serverUnavailable` retries with same key - [ ] Other errors discard key and generate new - [ ] Attestation object sent to server for validation (not validated on-device) **Assertion**: - [ ] Used only for sensitive operations (not every API call) - [ ] Payload hash covers the actual request data being protected - [ ] Server validates signature with stored public key - [ ] Server validates counter is strictly increasing **Server**: - [ ] Certificate chain validated against Apple's App Attest root CA - [ ] App identity hash (teamId + bundleId) verified - [ ] Counter stored and checked for strict increase - [ ] Public key associated with user account **Rollout**: - [ ] Server-controlled percentage (not client-side) - [ ] Gradual ramp with monitoring - [ ] Unattested users handled gracefully (lower trust, not blocked) - [ ] Rollback plan if attestation success rate drops ## Resources **WWDC**: 2021-10244 **Docs**: /devicecheck, /devicecheck/establishing-your-app-s-integrity, /devicecheck/validating-apps-that-connect-to-your-server **Skills**: axiom-cryptokit