Securing your mobile app and its API: only the real app, on a real device, over a channel you trust
App Attest, Play Integrity, server-side validation, certificate pinning, jailbreak, debugger and emulator detection, obfuscation, IP allowlists and request signing: how they fit together, with code, and how the mix differs for a retail app and an API-as-a-service product.
Your mobile app is not the front door to your product. Your API is. The app is just the most convenient client for it, and anyone can write another one: a script, a Postman collection, an emulator farm, or your own app repackaged with the checks removed. Securing a mobile product means making sure that the requests reaching your API come from your genuine app, running on a genuine device, over a channel nobody is sitting in the middle of, and that the server, not the client, decides what each request is allowed to do.
I've shipped both shapes this has to work for: retail apps that move money for consumers, and APIs that other companies build their products on. The controls overlap, but the mix is different, so this piece covers both.
Rule zero: assume the client is fully controlled by an attacker. Every check that runs on the device is a signal for the server, never a decision.
Two products, two threat models
- A retail app (a wallet, a remittance app, a lending app) talks to a private API that only your app should call. The threats are account takeover, bots and emulator farms opening accounts, repackaged apps that skip your checks, scraping, and promo or referral abuse. You can't keep a secret in the app, and you can't allowlist the IP addresses of millions of phones.
- An API-as-a-service product (payments, KYC, FX or payouts sold to other businesses) is called by your partners' servers. The threats are stolen API keys, replayed requests, a partner whose own systems are compromised, and abuse of rate and scope. Here you can hand out credentials, pin source IPs and sign every request, because the caller is a server you can hold to a contract.
The layers
- The app: is this the binary you built and signed? App Attest on iOS, Play Integrity on Android.
- The device: is it a real, uncompromised phone? Integrity verdicts, plus jailbreak, root, debugger, emulator and hooking signals.
- The channel: is anyone in the middle? TLS with certificate pinning.
- The request: did it really come from that app, or that partner, unchanged and only once? Attestation bound to the request on mobile; signatures, timestamps and nonces for partners.
- The server: given all of the above, what is this request allowed to do? Allow, step up, limit or deny.
App attestation: proving it's your app
App Attest (Apple's DeviceCheck framework, iOS 14 and later) generates a key pair inside the Secure Enclave, and Apple attests that the key belongs to your genuine app on a genuine Apple device. You attest the key once, store its public key on your server, and from then on the app signs sensitive requests with assertions from that key.
import CryptoKit
import DeviceCheck
let service = DCAppAttestService.shared
guard service.isSupported else { return } // older devices and the simulator: use a fallback path
// Once per install: create a key and have Apple attest it
let keyId = try await service.generateKey()
let challenge = try await api.fetchChallenge() // one-time value issued by your server
let attestation = try await service.attestKey(keyId, clientDataHash: Data(SHA256.hash(data: challenge)))
try await api.registerKey(keyId: keyId, attestation: attestation) // verified on the server, once
// Per sensitive request: sign the request plus a fresh server challenge with the attested key
let nonce = try await api.fetchChallenge()
let assertion = try await service.generateAssertion(keyId, clientDataHash: Data(SHA256.hash(data: requestBody + nonce)))On the server, follow Apple's validation steps exactly, or use a maintained library that does: check the certificate chain up to Apple's App Attest root, recompute the nonce from the authenticator data and your challenge, check the key ID matches the public key, check the relying-party ID is the hash of your team and bundle ID, check the counter starts at zero, check the aaguid is the production value, and check the credential ID equals the key ID. For each assertion afterwards, verify the signature with the stored key, the relying-party ID, that the counter has gone up, and that the client data contains the challenge you issued. Apple also recommends rolling attestation out gradually across a large user base.
Play Integrity is the Android equivalent: since SafetyNet Attestation was switched off in January 2025, it's Google's only managed attestation service. (Android Key Attestation, the platform feature underneath, proves a key is hardware-backed and reports the app's package, signing certificate and boot state, but Play Integrity is where most teams should start.) The app asks Google Play for a token; your server sends it to Google to decode, and gets back verdicts about the app (is this the build Play knows?), the device (does it pass integrity checks?) and the account (was the app installed from Play?). Use standard requests and bind each token to the request it protects with a request hash.
If you build in Flutter, Firebase App Check wraps both providers behind one API. Limited-use tokens can be consumed on the server so they can't be replayed:
await Firebase.initializeApp();
await FirebaseAppCheck.instance.activate(
appleProvider: AppleProvider.appAttestWithDeviceCheckFallback,
androidProvider: AndroidProvider.playIntegrity,
);
// Sensitive calls carry a single-use token the server will consume
dio.interceptors.add(InterceptorsWrapper(onRequest: (options, handler) async {
if (options.extra['sensitive'] == true) {
options.headers['X-App-Check'] = await FirebaseAppCheck.instance.getLimitedUseToken();
}
handler.next(options);
}));import { getAppCheck } from 'firebase-admin/app-check';
export async function requireGenuineApp(req, res, next) {
const token = req.get('X-App-Check');
if (!token) return res.status(401).json({ error: 'app_not_verified' });
try {
const result = await getAppCheck().verifyToken(token, { consume: true });
if (result.alreadyConsumed || !ALLOWED_APP_IDS.has(result.appId)) {
return res.status(401).json({ error: 'app_not_verified' });
}
next();
} catch {
res.status(401).json({ error: 'app_not_verified' });
}
}If you'd rather call Google directly, decode the Play Integrity token on your server and check every field that matters, not just the one that's convenient:
const playintegrity = google.playintegrity({ version: 'v1', auth });
const { data } = await playintegrity.v1.decodeIntegrityToken({
packageName: 'com.example.wallet',
requestBody: { integrityToken: token },
});
const p = data.tokenPayloadExternal;
const fresh = Date.now() - Number(p.requestDetails.timestampMillis) < 60_000;
const boundToThisRequest = p.requestDetails.requestHash === hashOf(canonicalRequest);
const ourPackage = p.requestDetails.requestPackageName === 'com.example.wallet';
const genuineApp = p.appIntegrity.appRecognitionVerdict === 'PLAY_RECOGNIZED';
const genuineDevice = (p.deviceIntegrity.deviceRecognitionVerdict ?? []).includes('MEETS_DEVICE_INTEGRITY');
const licensed = p.accountDetails?.appLicensingVerdict === 'LICENSED';
if (!fresh || !boundToThisRequest || !ourPackage || !genuineApp) return deny();
if (!genuineDevice || !licensed) return stepUp(); // strong signals, but it is your call whether to blockTwo practical limits. Play Integrity allows 10,000 requests a day by default (you can ask for more), and Apple throttles App Attest attestation, which you only do once per key; assertions are generated on the device and aren't limited. So attest once per install, ramp up gradually, and get a fresh standard token or assertion for each sensitive action rather than caching verdicts, because a cached verdict can be relayed from a good device. And plan for devices that can't attest: older iPhones, and in African markets especially, Huawei phones without Google Play services. Give them a fallback, such as HMS Safety Detect or a stricter risk-based path, instead of locking them out.
Server-side validation: where the decision lives
Attestation is worthless if the server doesn't check it properly, and theatre if it checks it but always says yes. The server should:
- Bind every token to a request. A server-issued nonce, or a hash of the method, path and body, so a token captured from one request can't be replayed on another.
- Check freshness and single use. Reject old tokens and consume limited-use ones.
- Check the app's identity, not just that a token exists: package or bundle ID, signing certificate and recognised version.
- Never trust a client-reported flag. An
isRooted: falsefield in a request body is exactly what a hooked app will send. - Decide in tiers. Allow, step up (re-authenticate or a second factor), limit (read-only, lower limits) or deny, based on the verdicts, the device's history and what the request does. Moving money deserves a higher bar than viewing a balance.
- Re-validate everything that matters on the server anyway: balances, limits, rates, ownership. The client is a convenience layer.
Certificate pinning and SSL pinning
“SSL pinning” is the umbrella term; what you actually pin is either a certificate or, better, a public key (the SPKI hash), so a renewed certificate with the same key keeps working. Pinning stops someone with a rogue or user-installed CA, on a hostile Wi-Fi network or in a debugging proxy, from reading and replaying your traffic. Always ship at least one backup pin, and know your certificate rotation dates.
Natively, Android has Network Security Config and iOS has NSPinnedDomains in Info.plist (iOS 14 and later):
<network-security-config>
<domain-config>
<domain includeSubdomains="true">api.example.com</domain>
<pin-set expiration="2026-06-30">
<pin digest="SHA-256">primaryKeySpkiHashBase64=</pin>
<pin digest="SHA-256">backupKeySpkiHashBase64=</pin>
</pin-set>
</domain-config>
</network-security-config>The Flutter gotcha: Dart's HttpClient does its own TLS and doesn't use the platform's pinning configuration, so in Flutter you pin in Dart. With Dio, the callback receives the server's leaf certificate:
final dio = Dio()
..httpClientAdapter = IOHttpClientAdapter(
validateCertificate: (cert, host, port) {
if (cert == null) return false;
final fingerprint = sha256.convert(cert.der).toString(); // package:crypto
return pinnedFingerprints.contains(fingerprint); // current and next certificate
},
);Keep a way out: pins that can be updated through a signed, pinned remote config, so a certificate change never strands old app versions. And be honest about the limit: on a rooted or jailbroken phone, Frida can switch pinning off in seconds. Pinning protects your users from the network. Attestation and device signals are what protect your API from the person holding the phone.
Jailbreak, root, debugger, emulator and hooking signals
These checks look for the conditions attackers need: elevated privileges, an attached debugger, a virtual device or instrumentation such as Frida. Each one can be bypassed on its own, which is why they're signals to combine on the server rather than gates in the app.
- Root and jailbreak:
subinaries, Magisk, Cydia and Sileo paths, writable system partitions, the ability to write outside the app sandbox. - Debugger:
Debug.isDebuggerConnected()and a debuggable build flag on Android; theP_TRACEDflag viasysctlon iOS. - Emulator: generic build fingerprints and goldfish or ranchu hardware on Android, the simulator target on iOS, and a failed device verdict from Play Integrity.
- Hooking: Frida's server and gadget libraries in the process's memory maps, its default port, and unexpected changes to your own code signature.
fun deviceSignals(context: Context): Map<String, Boolean> = mapOf(
"debuggerAttached" to (Debug.isDebuggerConnected() || Debug.waitingForDebugger()),
"debuggableBuild" to (context.applicationInfo.flags and ApplicationInfo.FLAG_DEBUGGABLE != 0),
"emulatorHints" to (Build.FINGERPRINT.contains("generic") || Build.PRODUCT.startsWith("sdk") ||
Build.HARDWARE.contains("goldfish") || Build.HARDWARE.contains("ranchu")),
"suBinary" to listOf("/system/bin/su", "/system/xbin/su", "/sbin/su").any { File(it).exists() },
)
// Send these with the attestation token. The server combines them; the app never decides alone.Don't hard-block on any single signal. Plenty of honest customers run older or modified Android phones, and a false positive at login is a lost customer. Let a risky device sign in and see its balance, and require step-up or keep limits low before it can move money.
Obfuscation: raising the cost, not hiding secrets
Obfuscation makes reverse engineering slower: R8 with isMinifyEnabled on Android, --obfuscate --split-debug-info=<dir> for Flutter builds, and stripped symbols on iOS, with commercial tools on top if your threat model justifies them. Keep the symbol files so crash reports stay readable. But obfuscation is not secrecy. Anything compiled into the app, including API keys and HMAC secrets, can be extracted, so the only safe secret on a client is one that isn't there.
Header signing in a mobile app: let the device's key sign
Teams often try to sign mobile requests with an HMAC secret embedded in the app. It feels secure and it isn't: the secret is extracted once and then every script can sign requests. The mobile equivalent of request signing is attestation-backed keys. An App Attest assertion signs a hash of the request with a key that never leaves the Secure Enclave, and a Play Integrity token carries the request hash you asked it to bind. Verify those on the server and you get what header signing promises, with no shared secret to steal.
For API-as-a-service: IP allowlists and request signing
When your callers are partner servers, you can do things a retail app can't. Start by allowlisting each partner's egress IPs at the edge, for example in AWS WAF in front of the partner routes:
resource "aws_wafv2_ip_set" "partner_egress" {
name = "partner-egress"
scope = "REGIONAL"
ip_address_version = "IPV4"
addresses = ["203.0.113.10/32", "198.51.100.0/28"] # one entry per partner, from the contract
}
# In the web ACL: block /partners/* unless the source IP is in partner_egress.An allowlist is a useful second fence, not an identity. Cloud egress IPs change and are sometimes shared, so it has to sit alongside a credential. Then sign every request: a canonical string of the method, path, timestamp, a nonce and a hash of the body, signed with a per-partner secret.
import crypto from 'node:crypto';
// req.rawBody: capture it with express.raw() or express.json({ verify })
export async function verifyPartner(req, res, next) {
try {
const keyId = req.get('X-Key-Id') ?? '';
const key = await partnerKeys.find(keyId); // per partner; two active during rotation
const timestamp = req.get('X-Timestamp') ?? '';
const nonce = req.get('X-Nonce') ?? '';
if (!key || !/^d{13}$/.test(timestamp) || !/^[w-]{16,64}$/.test(nonce) ||
Math.abs(Date.now() - Number(timestamp)) > 5 * 60_000) {
return res.sendStatus(401); // malformed or stale requests fail closed
}
const bodyHash = crypto.createHash('sha256').update(req.rawBody ?? Buffer.alloc(0)).digest('hex');
const canonical = [req.method, req.originalUrl, timestamp, nonce, bodyHash].join('\n');
const expected = crypto.createHmac('sha256', key.secret).update(canonical).digest();
const signature = Buffer.from(req.get('X-Signature') ?? '', 'hex');
if (signature.length !== expected.length || !crypto.timingSafeEqual(signature, expected)) {
return res.sendStatus(401);
}
// Seen this nonce from this key before? Then it's a replay. (Redis: SET keyId:nonce 1 NX EX 600)
if (!(await nonces.claim(keyId + ':' + nonce, 600))) return res.sendStatus(401);
req.partner = key.partner; // map to a partner identity with its own scopes and limits
next();
} catch (err) {
next(err);
}
}- Per-partner everything: keys, scopes, rate limits, quotas and a kill switch, so one partner's bad day doesn't become yours.
- Rotation without downtime: allow two active keys per partner while they switch.
- mTLS for partners who can support it: the client certificate becomes a second credential at the transport layer.
- Map partners to an identity inside your system, so your normal authorization applies and a partner key can only reach that partner's own resources.
What a retail product gains
- Your private API stays private in practice. Scripts, emulator farms and repackaged apps can no longer call it directly: they need a genuine app on a genuine device, which raises the cost of bot sign-ups, credential stuffing and promo abuse. Real-device farms and relayed tokens still get through, so keep rate limits and risk scoring behind it.
- Fewer account takeovers. A stolen password alone isn't enough when a new device must pass attestation and a step-up check before it can move money.
- Users protected from interception, because pinning stops anyone holding a rogue, mis-issued or user-installed certificate from reading the traffic.
- Lower fraud losses and support costs, and better evidence for your regulators and card schemes that your security of processing is real.
What an API-as-a-service product gains
- Authenticated requests. An HMAC shows your server that a request came from a holder of the partner's key, unchanged and only once. Because you hold the same secret, it can't settle a dispute with a third party; for that, have partners sign with a private key only they hold (Ed25519 or ECDSA, for example with RFC 9421 HTTP Message Signatures) and keep the signed requests.
- Containment. Per-partner keys, scopes and kill switches limit the damage of a compromised partner to that partner.
- Faster enterprise sales. Banks and large partners send long security questionnaires; IP allowlisting, request signing, mTLS and key rotation answer most of them before they're asked.
- Cleaner audits, because every partner request is attributable and logged.
Pitfalls I see most
- Checking attestation in the app and sending the server a boolean.
- Attestation tokens that aren't bound to the request, so they can be replayed.
- A single pin with no backup, which bricks the app on the next certificate renewal.
- Assuming platform pinning config protects Flutter's Dart HTTP client.
- Hard-blocking every rooted device, and losing honest customers to false positives.
- Embedding an API secret in the app because the binary is obfuscated.
- No fallback for devices that can't attest.
- Treating an IP allowlist as authentication.
For the categories behind most of this, the OWASP MASVS is the reference: its network, resilience and platform controls map closely to the layers above.