Native App Attestation
Native app attestation allows mobile apps to bypass rate limiting by proving the request originates from a genuine device running your authentic app. Native apps must use device attestation rather than CAPTCHA when encountering rate limits.
Why Use Attestation?
| Benefit | Description |
|---|---|
| Seamless UX | Rate limits bypassed automatically without user interaction |
| Native experience | No web views or external verification flows |
| Invisible to users | No “unusual activity” warnings or challenges |
Supported Methods
| Method | Platform | Enum Value |
|---|---|---|
| Apple DeviceCheck | iOS | APPLE_DEVICE_CHECK |
| Play Integrity | Android | ANDROID_PLAY_INTEGRITY |
Integration Flow
The flow differs slightly by platform. Apple DeviceCheck does not use a server-issued nonce — the device generates a token directly. Android Play Integrity uses a nonce provided by Horizon via the siteKey field.
iOS (Apple DeviceCheck)
┌─────────────────┐ ┌──────────────────┐ ┌─────────────────┐│ Your App │ │ Device OS │ │ Horizon API │└────────┬────────┘ └────────┬─────────┘ └────────┬────────┘ │ │ │ │ 1. API request │ │─────────────────────────────────────────────────>│ │ │ │ │ 2. Rate limit triggered (siteKey: null) │ │<─────────────────────────────────────────────────│ │ │ │ │ 3. Request device │ │ │ token (no nonce) │ │ │───────────────────────>│ │ │ │ │ │ 4. Device generates │ │ │ token │ │ │<───────────────────────│ │ │ │ │ │ 5. Retry with attestation headers │ │─────────────────────────────────────────────────>│ │ │ │ │ 6. Success (rate limit bypassed) │ │<─────────────────────────────────────────────────│Android (Play Integrity)
┌─────────────────┐ ┌──────────────────┐ ┌─────────────────┐│ Your App │ │ Device OS │ │ Horizon API │└────────┬────────┘ └────────┬─────────┘ └────────┬────────┘ │ │ │ │ 1. API request │ │─────────────────────────────────────────────────>│ │ │ │ │ 2. Rate limit triggered (siteKey: nonce) │ │<─────────────────────────────────────────────────│ │ │ │ │ 3. Request integrity │ │ │ token with nonce │ │ │───────────────────────>│ │ │ │ │ │ 4. Device generates │ │ │ signed token │ │ │<───────────────────────│ │ │ │ │ │ 5. Retry with attestation headers │ │─────────────────────────────────────────────────>│ │ │ │ │ 6. Success (rate limit bypassed) │ │<─────────────────────────────────────────────────│Detecting Rate Limits
When rate limiting triggers, the GraphQL response includes attestation options:
{ "data": { "login": null }, "errors": [ { "message": "Rate limit exceeded", "extensions": { "code": "RATE_LIMITED" } } ], "extensions": { "rateLimitersFiring": [ { "rateLimitingBucket": "LOGIN", "captchaBypassAvailable": [ { "type": "APPLE_DEVICE_CHECK", "siteKey": null }, { "type": "ANDROID_PLAY_INTEGRITY", "siteKey": "a1b2c3d4-nonce-value" } ] } ] }}| Field | Description |
|---|---|
rateLimitingBucket | Which rate limiter was triggered |
captchaBypassAvailable | Available verification methods |
type | The attestation type |
siteKey | Nonce for Android Play Integrity. Always null for Apple DeviceCheck (not required) |
Submitting Attestation
Include these headers when retrying a rate-limited request:
| Header | Value |
|---|---|
X-Captcha-Type | APPLE_DEVICE_CHECK or ANDROID_PLAY_INTEGRITY |
X-Captcha-Response | Base64-encoded token from device API |
// Mobile API with attestationfetch('https://api.thehut.net/myprotein/en/graphql', { method: 'POST', headers: { 'Content-Type': 'application/json', 'X-Captcha-Type': 'ANDROID_PLAY_INTEGRITY', 'X-Captcha-Response': attestationToken, 'Authorization': 'Opaque ' + authToken }, body: JSON.stringify({ query, variables })});iOS: Apple DeviceCheck
Horizon uses Apple’s DeviceCheck framework (DCDevice), not the App Attest framework (DCAppAttestService). The device generates a token directly — no server-issued nonce, no clientDataHash, and no key registration is involved.
import DeviceCheck
func generateDeviceToken() async throws -> String { guard DCDevice.current.isSupported else { throw AttestationError.deviceCheckUnavailable }
let tokenData = try await DCDevice.current.generateToken() return tokenData.base64EncodedString()}Send the token as the X-Captcha-Response header. A fresh token is required per attempt — Horizon enforces single-use.
Prerequisites:
- iOS 11.0 or later required
- Physical device only (DeviceCheck is not available in the iOS Simulator)
Android: Play Integrity API
Add the Play Integrity dependency:
dependencies { implementation 'com.google.android.play:integrity:1.3.0'}Generate integrity tokens using the nonce from the siteKey field:
import com.google.android.play.core.integrity.IntegrityManagerFactoryimport com.google.android.play.core.integrity.IntegrityTokenRequest
suspend fun generateIntegrityToken(nonce: String): String { val integrityManager = IntegrityManagerFactory.create(context)
val request = IntegrityTokenRequest.builder() .setNonce(nonce) .build()
val response = integrityManager .requestIntegrityToken(request) .await()
return response.token()}Prerequisites:
- Link app in Google Play Console
- Enable Play Integrity API in Google Cloud Console
Error Handling
| Failure | Platform | Cause | Resolution |
|---|---|---|---|
| Invalid token format | Both | Malformed or corrupted token | Regenerate token |
| Nonce mismatch | Android | Wrong nonce used | Use siteKey from rate limit response |
| Expired nonce | Android | Token generated too long ago | Generate fresh token immediately |
| Replay detected | Both | Same token used twice | Generate new token for each request |
| Device integrity failed | Both | Rooted/jailbroken device | Cannot bypass on compromised devices |
Handling Attestation Failures
If attestation fails (e.g., on a compromised device), inform the user that the action cannot be completed:
async function handleRateLimit(response) { const rateLimitInfo = response.extensions?.rateLimitersFiring?.[0]; if (!rateLimitInfo) return;
const attestation = await getAttestation(rateLimitInfo.captchaBypassAvailable); if (attestation) { return retryWithAttestation(attestation); }
// Attestation unavailable or failed showError('Unable to verify device. Please try again later.');}Device Limitations
| Scenario | Behaviour |
|---|---|
| iOS Simulator | DeviceCheck not available |
| Android Emulator | Play Integrity may fail |
| Rooted Android | Device integrity check fails |
| Jailbroken iOS | DeviceCheck may fail |
| Sideloaded app | App integrity check fails |