A while back I was iOS lead on a food-delivery app running a first-order discount — mobile-only by design. No web checkout. Marketing wanted the conversion that native-only gives, and on paper it shrinks the fraud surface too: a scraper can't place an order, take payment, and route a delivery without an app.
A few days in, the fraud team flagged it. Redemptions were running several times the model, and almost every redeeming account had been created inside the launch window. No single account had tripped a per-account rate limit — the pattern only existed in the aggregate. By the time the lagging alert fired, a lot of fraudulent redemptions had already cleared.
One soft signal sharpened the picture: session records showed traffic skewed iOS-heavy. That's an attacker-controlled field — a Python script can claim any user-agent it wants — but paired with "mobile-only campaign," it pointed the first audit at the iOS binary.
The binary is the spec
I ran Hopper against a production IPA — the same thing the attacker had done. The binary was readable, and that was the whole problem: endpoint paths, the request-signing logic, the auth header format, the entire promo flow were all extractable from it. No tampering required. Just reading.
Cross-referencing with server logs filled in the rest. The fraudulent requests had correct headers, correct signatures, correct timing — but distinct device IDs, fresh accounts, a residential-proxy IP pool, and inhuman funnel velocity. Someone had pulled the IPA off a jailbroken device, reverse-engineered the API from the binary, and scripted the whole order-and-redeem flow in Python: programmatic account creation, real card tokens at the payment step, drop-point delivery addresses.
Here's the asymmetry that matters:
The app and the Python script looked identical on the wire. Client-side rate limiting was useless — the attacker wasn't using the app. Jailbreak detection was useless for the same reason. The server had no cryptographic way to tell "this came from our app" from "this came from a script that read our app."
That last sentence is the entire engineering problem. The binary you ship is a specification you hand to anyone who wants it: the signing scheme isn't a secret, it's documentation. Everything client-side is a speed bump, because the attacker owns the device — jailbreak checks, obfuscation, integrity self-tests all run on hardware the attacker controls and can patch out. The only durable signal is one the server can verify and the client can't forge.
App Attest, per request
The fix was App Attest with per-request assertions on the money endpoints — and the distinction between attestation and assertion is the whole point.
Attestation is the one-time handshake: at first launch the app generates a key in the Secure Enclave, and Apple vouches — cryptographically — that this key belongs to a genuine, unmodified build of your bundle ID, running on real Apple hardware. You verify that attestation server-side once and store the key ID. But a one-time "this device is legit" stamp doesn't help here: the attacker could attest one real device and then fire a million scripted requests under its blessing.
So you use the key on every sensitive call. An assertion is a fresh Secure Enclave signature over the specific request payload (plus a server-issued challenge to kill replay). Each promo, order, and payment request carries a signature that only that enclave, running that binary, on that device, can produce.
func sensitiveRequest(endpoint: String, body: Data) async throws {
guard let keyID = Keychain.load("attestKeyID") else {
throw AttestError.notAttested
}
let clientDataHash = Data(SHA256.hash(data: body))
let assertion = try await DCAppAttestService.shared
.generateAssertion(keyID, clientDataHash: clientDataHash)
// Server rejects the request if the assertion doesn't verify.
try await client.post(endpoint, body: body, assertion: assertion)
}
A Python script can't produce a valid assertion. It would need a fleet of real Apple devices with intact Secure Enclaves running the genuine, unmodified binary on every call. The binary being readable stops mattering the moment each sensitive request needs an unforgeable per-device signature. The spec is still open; the lock just moved to hardware the attacker can't replicate at scale.
I scoped assertions to the money endpoints only — promo, order, payment. An assertion is a Secure-Enclave signature plus a server-side verify against Apple's cached public key; you put that tax where the value is, not on every request.
Making it hold up in production
Two operational decisions mattered as much as the primitive:
- Phase 1**Instrument.** The server counts assertion rejections and enforces nothing. You learn the real-world rejection sources you didn't predict — simulators (which can't attest), stale or rotated attestation state, first-launch races, users restoring from backup. You tune against that baseline before it can hurt anyone.
- Phase 2**Soft-enforce.** The server rejects with a *recoverable* error code and the client has a fallback path. Genuine users caught in an edge state — stale attestation, a network race — see a retry, not a wall. You catch the long tail of false positives here.
- Phase 3**Hard-enforce.** The server rejects with `403` and no fallback. Scripts get nothing. By the hard cut, real clients have already proven they pass under soft enforcement for a week.
And ship Android in lockstep. Play Integrity on the same release train, not two weeks later. Protect one platform first and the attacker just pivots to the other within a single campaign cycle, and you're back in the same incident. The Android lead and I aligned the rollout windows to the same train, phase for phase.
Two more layers
Attestation isn't a silver bullet, so it doesn't stand alone.
DeviceCheck gives you persistent device-level memory. It's two bits per device, per developer, held by Apple — and crucially, those bits survive app uninstall and reinstall. When fraud is confirmed on a device, the server sets the flag, and the reinstall-and-repeat loop is closed for that device regardless of what it does with attestation afterward. Two bits is plenty for a binary "fraud-confirmed" flag; richer reputation lives server-side.
Session-depth scoring as a leading indicator. The redemption-rate alert that caught the original attack was a lagging indicator — it only fires after the damage clears the aggregate. A leading indicator watches behavior in near-real-time: an account-to-checkout funnel completed in under a minute reads nothing like an organic median measured in minutes. That gap surfaces immediately, instead of waiting for the redemption aggregate to drift.
Where it landed, and where it didn't
The next campaign — the first through the new process — ran clean. Anomalous redemptions dropped sharply and the redemption rate fell back inside the noise band.
It didn't go to zero. A small residual switched to running the genuine binary on jailbroken devices and Frida-hooking the request signer — letting attestation pass, then rewriting parameters after the signature was produced. This is the honest limit of the primitive: App Attest certifies "genuine unmodified binary on a real device," not "nobody hooked it at runtime." It's a layer, not the answer.
But the residual hit the other two layers. The velocity scoring caught those sessions on behavior, and DeviceCheck flagged the devices permanently across reinstalls. Stacking weak-but-independent signals — attestation, velocity, account age, device reputation, proxy detection — is the actual strategy: evading any one is cheap, evading all of them at once is expensive. The attack didn't end; the economics broke. At our discount margins it stopped being profit-positive, and that's the real win — unprofitable, not impossible.
The part that wasn't code
The honest version of this story is that the technical primitives — App Attest, Play Integrity, DeviceCheck — were well understood by everyone in the war room. The failure wasn't knowledge. It was structure.
Five teams each owned a fragment of the promo funnel. The first time anyone mapped the surface end-to-end was in a session after the incident — and the map made the gap obvious:
| Team | Surface | Owned? |
|---|---|---|
| iOS | App binary, request signing, on-device fraud signals — the binary was the open spec | Yes |
| Android | Same shape, different binary, equally readable via APKTool / jadx — parity matters | Yes |
| Backend | Promo redemption endpoint, request validation, signature verification | Yes |
| Fraud | Scoring thresholds, rate limits, abuse detection — lagging indicators only | Yes |
| Analytics | Session telemetry, account-age distributions, funnel velocity | Yes |
| — Seam — | "Is this request from our genuine binary on a real device?" — crosses iOS + backend + fraud | No one |
Every box was owned. The attack lived in the line between the boxes. "Is this request coming from our genuine binary on a real device" is a question that belongs to no single team, so no single team had been asked it before launch. The attacker walked the seam.
So the durable fix wasn't the new code. It was changing who has to be asked:
- A cross-team fraud-surface review as a launch gate for anything revenue-critical. iOS, Android, backend, fraud, and analytics sign off, and the review artifact attaches to the launch ticket. It's a gate that produces an artifact, not a recurring meeting — which is why it stuck instead of getting routed around.
- An ownership matrix that names the seams as jointly owned — "binary-to-server trust" is iOS + backend + fraud, explicitly, together.
- Leading-indicator instrumentation required at launch for any campaign with abuse potential — session-depth scoring, account-age baselines, platform-mix anomaly bands — so detection no longer waits for a lagging aggregate.
- Fraud-surface review
- None
- Ownership of seams
- None
- Detection
- Lagging only
- Incident playbook
- None
- Fraud-surface review
- Launch gate
- Ownership of seams
- Joint, documented
- Detection
- Leading + lagging
- Incident playbook
- Runbook
Subsequent campaigns ran without flagged anomalies, and the gate became standard for any revenue-critical feature, not just promos. The playbook got used once more, well over a year later, for a related-but-different attack — and that time the first thirty minutes weren't improvised.