Skip to content

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?

BenefitDescription
Seamless UXRate limits bypassed automatically without user interaction
Native experienceNo web views or external verification flows
Invisible to usersNo “unusual activity” warnings or challenges

Supported Methods

MethodPlatformEnum Value
Apple DeviceCheckiOSAPPLE_DEVICE_CHECK
Play IntegrityAndroidANDROID_PLAY_INTEGRITY

Integration Flow

┌─────────────────┐ ┌──────────────────┐ ┌─────────────────┐
│ Your App │ │ Device OS │ │ Horizon API │
└────────┬────────┘ └────────┬─────────┘ └────────┬────────┘
│ │ │
│ 1. API request │
│─────────────────────────────────────────────────>│
│ │ │
│ 2. Rate limit triggered (includes nonce) │
│<─────────────────────────────────────────────────│
│ │ │
│ 3. Request attestation│ │
│ 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"
}
]
}
]
}
}
FieldDescription
rateLimitingBucketWhich rate limiter was triggered
captchaBypassAvailableAvailable verification methods
typeThe attestation type
siteKeyNonce for attestation request

Submitting Attestation

Include these headers when retrying a rate-limited request:

HeaderValue
X-Captcha-TypeAPPLE_DEVICE_CHECK or ANDROID_PLAY_INTEGRITY
X-Captcha-ResponseBase64-encoded token from device API
// Mobile API with attestation
fetch('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

Use the DCAppAttestService to generate attestation tokens:

import DeviceCheck
import CryptoKit
@available(iOS 14.0, *)
func generateAttestation(nonce: String) async throws -> String {
let keyId = try await getOrCreateAttestationKey()
let nonceData = Data(nonce.utf8)
let hash = SHA256.hash(data: nonceData)
let hashData = Data(hash)
let assertion = try await DCAppAttestService.shared.generateAssertion(
keyId,
clientDataHash: hashData
)
return assertion.base64EncodedString()
}

Prerequisites:

  • Enable App Attest capability in Xcode
  • Configure App ID in Apple Developer Portal
  • iOS 14.0 or later required

Android: Play Integrity API

Add the Play Integrity dependency:

dependencies {
implementation 'com.google.android.play:integrity:1.3.0'
}

Generate integrity tokens:

import com.google.android.play.core.integrity.IntegrityManagerFactory
import 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

FailureCauseResolution
Invalid token formatMalformed or corrupted tokenRegenerate token
Nonce mismatchWrong nonce usedUse siteKey from rate limit response
Expired nonceToken generated too long agoGenerate fresh token immediately
Replay detectedSame token used twiceGenerate new token for each request
Device integrity failedRooted/jailbroken deviceCannot 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

ScenarioBehaviour
iOS SimulatorDeviceCheck not available
Android EmulatorPlay Integrity may fail
Rooted AndroidDevice integrity check fails
Jailbroken iOSApp Attest may fail
Sideloaded appApp integrity check fails