#The Problem
You're building a mobile app that needs to call your backend API. The API serves public data — no user login required. But you still need to protect it from abuse: bots, scrapers, and unauthorized clients hammering your endpoints.
The instinct is to add a static API token:
// Inside your mobile app$response = Http::acceptJson() ->withToken('hard-coded-token-343ffs56777') ->get('https://yourdomain.com/api/public-data');
// On your backendif ($request->bearerToken() !== 'hard-coded-token-343ffs56777') { return response()->json(['message' => 'Unauthorized'], 401);}
This feels secure. It isn't.
#Why Static Tokens Don't Work
Mobile app binaries are not secret. On Android, an APK is a zip file. Anyone can download it, extract it, and read every string inside — including your token. Tools like apktool, jadx, and dex2jar make this trivial. On iOS, the situation is slightly better due to encryption, but determined attackers can still extract strings from decrypted binaries on jailbroken devices.
Once your token is extracted, an attacker can:
- Call your API directly from scripts, bypassing your app entirely
- Scrape all your data at scale
- Abuse your infrastructure with unlimited requests
- Impersonate your app in ways you can't distinguish from real traffic
Obfuscation (ProGuard, R8, string encryption) raises the bar slightly, but it's a speed bump, not a wall. Security through obscurity is not security.
The core principle: you cannot trust the client. Any secret that ships in the binary is compromised by definition.
#Solution 1: Platform Attestation
This is the gold standard. Instead of embedding a secret in your app, you ask the operating system itself to vouch for your app's legitimacy.
#How It Works
Both Android and iOS provide attestation APIs that generate cryptographic proof of three things:
- App integrity — the app binary hasn't been modified or repackaged
- Device integrity — the app is running on a real device, not an emulator or rooted/jailbroken environment
- Account integrity (Android) — the Google Play account is in good standing
Your app requests an attestation token from the OS, sends it to your backend, and your backend verifies it directly with Google or Apple's servers. No shared secret ever touches the app binary.
#Android: Play Integrity API
App Your Backend Google Servers | | | |-- Request nonce -------->| | |<-- Return nonce ---------| | | | | |-- Request integrity | | | token from Google ---->| | | (with nonce) | | |<-- Integrity token ------| | | | | |-- Send token to -------->| | | your backend |-- Verify token --------->| | |<-- Verification result --| | | | |<-- Short-lived JWT ------| | | | | |-- Use JWT for API ------>| | | calls (15 min TTL) | |
The Play Integrity API returns a signed verdict containing:
- Request details — confirms the nonce matches what your server generated
- App integrity — the app's signing certificate matches what's registered in Play Console
- Device integrity — the device passes CTS (Compatibility Test Suite) checks
- Account integrity — the user's Play account licensing status
Your backend decrypts and verifies this verdict using Google's servers, then issues a short-lived token for subsequent API calls.
#iOS: App Attest
Apple's equivalent is the App Attest service, part of the DeviceCheck framework. The flow is similar:
- App generates a cryptographic key pair tied to the device
- App requests attestation from Apple, which signs the public key
- Your backend verifies the attestation with Apple
- For each API request, the app generates an assertion (a signature over the request data)
- Your backend verifies the assertion against the attested public key
App Attest provides stronger per-request guarantees than Play Integrity because each request is individually signed, not just the initial handshake.
#Trade-offs
Strengths:
- Cryptographically sound — no secret in the binary to extract
- Backed by OS-level guarantees that are extremely difficult to forge
- Covers app tampering, emulators, and rooted/jailbroken devices
Weaknesses:
- Requires Google Play Services (excludes some Android devices, Huawei ecosystem)
- Adds latency to the initial handshake (attestation verification is a network call)
- Play Integrity has daily request quotas (10,000 classic requests/day on the free tier)
- Determined attackers with rooted devices and Xposed/Frida frameworks can sometimes bypass attestation, though this is an ongoing arms race
#Solution 2: Request Signing with Nonce and Timestamp
If platform attestation is overkill for your threat model, request signing is a solid middle ground. Instead of sending a static token, each request includes a unique signature computed from the request data, a timestamp, and a nonce.
#How It Works
// Client side$timestamp = now()->timestamp;$nonce = Str::random(32);$body = json_encode($requestData);$payload = $timestamp . '|' . $nonce . '|' . $body;$signature = hash_hmac('sha256', $payload, $signingKey); $response = Http::withHeaders([ 'X-Timestamp' => $timestamp, 'X-Nonce' => $nonce, 'X-Signature' => $signature,])->post('https://yourdomain.com/api/data', $requestData);
// Server sidepublic function verifyRequest(Request $request): bool{ $timestamp = $request->header('X-Timestamp'); $nonce = $request->header('X-Nonce'); $signature = $request->header('X-Signature'); // Reject requests older than 5 minutes if (abs(now()->timestamp - $timestamp) > 300) { return false; } // Reject replayed nonces $nonceKey = 'nonce:' . $nonce; if (Cache::has($nonceKey)) { return false; } Cache::put($nonceKey, true, 600); // Verify signature $payload = $timestamp . '|' . $nonce . '|' . $request->getContent(); $expected = hash_hmac('sha256', $payload, $this->signingKey); return hash_equals($expected, $signature);}
#Why It's Better Than a Static Token
- Replay attacks are blocked. Each nonce can only be used once. An attacker can't capture a valid request and resend it.
- Stale requests are rejected. The timestamp window means captured requests expire quickly.
- The signing key alone isn't enough. An attacker needs to understand the signing algorithm, not just extract a string. This pairs well with code obfuscation.
#Trade-offs
Strengths:
- No external service dependency (no Google/Apple verification calls)
- Works on all devices regardless of Play Services or jailbreak status
- Low latency — signing is a local operation
Weaknesses:
- The signing key is still in the binary. A determined reverse engineer can extract it and replicate the signing logic.
- You're relying on obscurity of the signing implementation, which is a weaker guarantee than cryptographic attestation.
- Doesn't verify app or device integrity — a modified app can still sign requests correctly.
#Solution 3: Server-Side Rate Limiting and Abuse Detection
This approach accepts a pragmatic truth: if your API serves public data, it's public. Instead of trying to lock down the client, you make abuse expensive and detectable on the server.
#Layers of Defense
Rate limiting by IP:
// In your route or middlewareRateLimiter::for('public-api', function (Request $request) { return Limit::perMinute(60)->by($request->ip());});
Rate limiting by device fingerprint:
Combine multiple signals — IP address, user agent, screen resolution, timezone — into a device fingerprint. Rate limit per fingerprint to catch attackers rotating IPs.
Anomaly detection:
- Flag sudden traffic spikes from a single source
- Detect geographic anomalies (requests from countries where you have no users)
- Monitor for sequential data access patterns (scraping walks through IDs in order)
- Track request timing (humans have variable intervals, bots are metronomic)
Response throttling:
- Enforce pagination limits (never return more than N items per request)
- Add artificial delays that scale with request volume
- Return progressively less data to suspected abusers
#Trade-offs
Strengths:
- No client-side changes required
- Works regardless of how the client accesses your API
- Can be implemented incrementally
- Catches abuse from any source, including legitimate apps used maliciously
Weaknesses:
- Reactive, not preventive — abuse happens before it's detected
- Sophisticated attackers can distribute requests across many IPs
- Doesn't prevent data extraction, only slows it down
- Requires ongoing monitoring and tuning
#Recommended Architecture: Defense in Depth
No single solution is bulletproof. The strongest approach layers multiple techniques:
Request Flow Mobile App | | 1. Attestation handshake (on app launch / token expiry) | - Play Integrity (Android) / App Attest (iOS) | - Backend verifies with Google/Apple | - Backend issues short-lived JWT (15 min TTL) | | 2. Signed API requests (on every call) | - HMAC signature over timestamp + nonce + body | - JWT in Authorization header | vYour Backend | | 3. Server-side validation | - Verify JWT (not expired, valid signature) | - Verify HMAC signature | - Check nonce uniqueness (reject replays) | - Check timestamp freshness (5 min window) | | 4. Rate limiting and monitoring | - Per-IP and per-device rate limits | - Anomaly detection | - Abuse logging and alerting | v Response
#Layer 1: Attestation (prevents unauthorized clients)
Stops the vast majority of abuse. Bots, scripts, and modified apps can't obtain valid attestation tokens. This is your front door lock.
#Layer 2: Request signing (prevents replay and tampering)
Even if an attacker somehow obtains a valid JWT, they can't replay or modify requests. Each request is uniquely signed.
#Layer 3: Short-lived tokens (limits blast radius)
If a token is compromised, it expires in 15 minutes. The attacker needs to repeat the attestation flow to get a new one — which they can't do from an unauthorized client.
#Layer 4: Server-side rate limiting (catches everything else)
The safety net. Even if all client-side protections are bypassed, aggressive rate limiting and anomaly detection make large-scale abuse impractical.
#What About Certificate Pinning?
Certificate pinning (ensuring your app only communicates with your specific server certificate) is sometimes mentioned in this context. It prevents man-in-the-middle attacks and makes it harder to intercept traffic with proxy tools like Charles or mitmproxy.
However, certificate pinning does not solve the core problem. It protects the transport layer, not the authentication layer. An attacker who decompiles your app doesn't need to intercept traffic — they already have the token. Certificate pinning is a useful defense-in-depth measure, but it's not a substitute for attestation or request signing.
#Choosing the Right Approach
| Scenario | Recommended Approach |
|---|---|
| Sensitive data, high-value target | Full defense in depth (all 4 layers) |
| Moderate data, standard app | Attestation + rate limiting |
| Low-sensitivity public data | Rate limiting + request signing |
| Prototype or MVP | Rate limiting only |
The key question to ask: what happens if someone bypasses your protection? If the answer is "they get data that's basically public anyway," heavy client-side protection may not be worth the complexity. If the answer involves financial loss, competitive damage, or privacy violations, invest in attestation.
#Summary
- Static tokens in mobile binaries are not secrets. Treat them as public.
- Platform attestation (Play Integrity / App Attest) is the only approach that provides cryptographic proof of client identity.
- Request signing with nonces and timestamps blocks replay attacks and raises the reverse engineering bar.
- Server-side rate limiting is your safety net and should always be present.
- Layer these approaches based on your threat model. More sensitive data warrants more layers.
The uncomfortable truth is that no client-side protection is absolute. A sufficiently motivated attacker with enough time and skill can bypass any client-side measure. The goal is to make abuse expensive enough that it's not worth the effort — and to detect and respond to it when it happens.