Web Security

Mobile API Security: Protecting APIs Consumed by iOS and Android Apps

Certificate pinning, secure API key storage in mobile apps, jailbreak and root detection strategies, and effective rate limiting for mobile clients.

March 9, 20267 min readShipSafer Team

Mobile API Security: Protecting APIs Consumed by iOS and Android Apps

Mobile applications present a distinct API security challenge: the client is untrusted by definition. Unlike a server-side consumer, mobile apps run on devices you do not control, can be reverse-engineered, and can be run in modified environments by attackers. Your API must assume that any secret embedded in the app binary is compromised, any device may be rooted or jailbroken, and traffic may be intercepted with a proxy.

This does not mean mobile API security is hopeless. It means the threat model is different from web APIs, and the controls need to match.

The Mobile API Threat Model

Attackers targeting mobile APIs have a richer set of techniques than browser-based attackers:

  • Static analysis: Decompile the APK or IPA, extract API keys, JWT signing secrets, and hardcoded URLs.
  • Dynamic analysis: Proxy all HTTPS traffic through Burp Suite or mitmproxy to observe requests and responses.
  • Hooking frameworks: Use Frida or Xposed to modify application behavior at runtime, bypass SSL pinning, or extract secrets from memory.
  • Emulator farming: Run modified versions of the app on hundreds of emulated devices for automated credential stuffing or account creation abuse.

Your API must be defensible assuming all of the above.

Certificate Pinning

Certificate pinning is a client-side control where the app refuses to establish a TLS connection unless the server's certificate (or its public key) matches a pre-stored value. This defeats proxying attacks — Burp Suite installs its own CA certificate and presents its own certificate to the app. With pinning, this results in a connection failure rather than successful interception.

iOS (URLSession with pinning)

import Foundation
import CryptoKit

class PinnedSessionDelegate: NSObject, URLSessionDelegate {
    // SHA-256 of the server's SubjectPublicKeyInfo (DER encoded)
    private let pinnedPublicKeyHashes: Set<String> = [
        "sha256/base64encodedHash1=",  // Current cert
        "sha256/base64encodedHash2=",  // Backup cert
    ]

    func urlSession(
        _ session: URLSession,
        didReceive challenge: URLAuthenticationChallenge,
        completionHandler: @escaping (URLSession.AuthChallengeDisposition, URLCredential?) -> Void
    ) {
        guard
            challenge.protectionSpace.authenticationMethod == NSURLAuthenticationMethodServerTrust,
            let serverTrust = challenge.protectionSpace.serverTrust,
            SecTrustGetCertificateCount(serverTrust) > 0,
            let cert = SecTrustGetCertificateAtIndex(serverTrust, 0)
        else {
            completionHandler(.cancelAuthenticationChallenge, nil)
            return
        }

        let publicKey = SecCertificateCopyKey(cert)
        // ... compute SHA-256 of the DER-encoded public key
        // Compare against pinnedPublicKeyHashes
        // If match: completionHandler(.useCredential, URLCredential(trust: serverTrust))
        // If no match: completionHandler(.cancelAuthenticationChallenge, nil)
    }
}

In practice, use TrustKit (iOS/macOS) which handles the implementation and supports graceful fallback during rotation.

Android (Network Security Config)

Android provides a declarative pinning mechanism via network_security_config.xml:

<!-- res/xml/network_security_config.xml -->
<?xml version="1.0" encoding="utf-8"?>
<network-security-config>
    <domain-config cleartextTrafficPermitted="false">
        <domain includeSubdomains="false">api.yourapp.com</domain>
        <pin-set expiration="2027-01-01">
            <!-- sha256 of SubjectPublicKeyInfo (base64) -->
            <pin digest="SHA-256">base64encodedHash1=</pin>
            <pin digest="SHA-256">base64encodedHash2=</pin>  <!-- Backup -->
        </pin-set>
    </domain-config>
</network-security-config>

Reference it in AndroidManifest.xml:

<application
    android:networkSecurityConfig="@xml/network_security_config"
    ...>

Pinning backup keys

Always pin at least two public keys: your current certificate's key and a backup key you have generated and stored securely but not yet deployed. If you pin only the current certificate and it expires or needs emergency replacement, all deployed app versions stop working until users update. The backup pin means you can deploy the backup certificate without a new app release.

Generate a backup pin:

openssl genrsa -out backup.key 2048
openssl req -new -x509 -key backup.key -out backup.crt -days 3650
openssl x509 -in backup.crt -pubkey -noout | \
  openssl pkey -pubin -outform DER | \
  openssl dgst -sha256 -binary | \
  base64

Handling Frida bypass

Attackers use Frida to hook the pinning functions at runtime and make them return success regardless. Countering this requires:

  • Obfuscating the pinning code so hooks cannot easily find the function.
  • Implementing multiple independent pinning checks in different parts of the code.
  • Using anti-tampering libraries (see below).

Pinning is defense-in-depth, not a complete solution. Determined attackers with physical access to a device can bypass it. Its primary value is raising the cost of attack above the threshold of casual researchers and automated proxy tooling.

API Key Storage in Mobile Apps

Never embed a static API key in your app binary. It will be extracted. Period.

What to do instead

Authenticate with user credentials, receive short-lived tokens: The correct pattern is for users to authenticate (username/password, OAuth, biometric), receive a short-lived JWT or session token, and have that token refreshed periodically. There is no static secret to extract.

Use platform secure storage for tokens:

iOS: Keychain Services

import Security

func storeToken(token: String, key: String) throws {
    let data = token.data(using: .utf8)!
    let query: [String: Any] = [
        kSecClass as String: kSecClassGenericPassword,
        kSecAttrAccount as String: key,
        kSecValueData as String: data,
        kSecAttrAccessible as String: kSecAttrAccessibleWhenUnlockedThisDeviceOnly,
    ]
    SecItemDelete(query as CFDictionary)
    let status = SecItemAdd(query as CFDictionary, nil)
    guard status == errSecSuccess else { throw KeychainError.unhandledError(status: status) }
}

Android: Encrypted SharedPreferences (via Jetpack Security)

val masterKey = MasterKey.Builder(context)
    .setKeyScheme(MasterKey.KeyScheme.AES256_GCM)
    .build()

val sharedPreferences = EncryptedSharedPreferences.create(
    context,
    "secure_prefs",
    masterKey,
    EncryptedSharedPreferences.PrefKeyEncryptionScheme.AES256_SIV,
    EncryptedSharedPreferences.PrefValueEncryptionScheme.AES256_GCM
)

sharedPreferences.edit().putString("auth_token", token).apply()

Never store tokens in:

  • UserDefaults (iOS) — unencrypted, accessible to backup tools.
  • Regular SharedPreferences (Android) — unencrypted.
  • Log files — frequently exposed in crash reporting.
  • AsyncStorage in React Native — unencrypted by default.

Jailbreak and Root Detection

Jailbroken (iOS) and rooted (Android) devices provide attackers with elevated access to your app's memory, filesystem, and network connections. Detection does not prevent all attacks but raises the bar significantly for dynamic analysis.

iOS jailbreak detection signals

func isDeviceJailbroken() -> Bool {
    // 1. Check for jailbreak files
    let jailbreakPaths = [
        "/Applications/Cydia.app",
        "/usr/sbin/sshd",
        "/etc/apt",
        "/private/var/lib/apt/",
        "/bin/bash",
    ]
    for path in jailbreakPaths {
        if FileManager.default.fileExists(atPath: path) { return true }
    }

    // 2. Try to write outside sandbox (should fail on non-jailbroken)
    do {
        try "test".write(toFile: "/private/jailbreak_test", atomically: true, encoding: .utf8)
        try FileManager.default.removeItem(atPath: "/private/jailbreak_test")
        return true
    } catch {}

    // 3. Check if Cydia URL scheme is registered
    if let url = URL(string: "cydia://"), UIApplication.shared.canOpenURL(url) {
        return true
    }

    return false
}

Android root detection

fun isDeviceRooted(): Boolean {
    // 1. Check for su binary
    val suPaths = arrayOf("/system/bin/su", "/system/xbin/su", "/sbin/su", "/su/bin/su")
    for (path in suPaths) {
        if (File(path).exists()) return true
    }

    // 2. Check build tags
    val buildTags = android.os.Build.TAGS
    if (buildTags != null && buildTags.contains("test-keys")) return true

    // 3. Attempt to run su command
    return try {
        Runtime.getRuntime().exec(arrayOf("/system/xbin/su", "-c", "id"))
        true
    } catch (e: Exception) {
        false
    }
}

Combine multiple signals. A single check is easy to patch with Frida. The more checks you have, the harder it is to bypass all of them simultaneously. For high-security applications, use commercial RASP (Runtime Application Self-Protection) solutions like Approov, Shield (formerly Appdome), or Data Theorem.

Rate Limiting Mobile Clients

Mobile apps make API requests at higher frequency than browsers (polling, background sync, push notification handling). Rate limiting must account for this while still blocking abuse.

Identifying mobile clients

Mobile clients should identify themselves with a consistent User-Agent and a device attestation token:

User-Agent: MyApp/2.1.0 (iOS 18.0; iPhone14,5)
X-Device-Attestation: <Apple App Attest token or Android Play Integrity token>

Apple App Attest and Google Play Integrity provide cryptographic proof that a request originates from a genuine, unmodified app on an uncompromised device. Use these for your highest-value endpoints.

Rate limiting strategy

// Different limits for different client types
const rateLimits = {
  authenticated_mobile: { requests: 300, window: '1m' },  // Logged-in mobile user
  authenticated_web: { requests: 100, window: '1m' },     // Logged-in web user
  unauthenticated: { requests: 20, window: '1m' },        // No auth
  sensitive_endpoints: { requests: 5, window: '15m' },    // Login, password reset
};

// Key by user ID for authenticated requests, by IP for unauthenticated
function getRateLimitKey(req: Request): string {
  if (req.user?.userId) return `user:${req.user.userId}`;
  return `ip:${req.ip}`;
}

For API endpoints that should never be called more than once per user action (payment submission, account deletion), implement idempotency keys that deduplicate repeat requests rather than rejecting them with a rate limit error.

Mobile API security requires layering controls: certificate pinning raises the cost of traffic interception, platform-native secure storage protects tokens at rest, integrity attestation verifies client authenticity, and rate limiting controls abuse at scale. No single control is sufficient on its own, but together they make attacking your mobile API substantially harder than attacking an unprotected API.

Check Your Security Score — Free

See exactly how your domain scores on DMARC, TLS, HTTP headers, and 25+ other automated security checks in under 60 seconds.