Mobile App Security: Protecting Data on Untrusted Devices
Implement mobile security that protects user data, API keys, and business logic on devices you do not control. Covers secure storage, certificate pinning, code obfuscation, biometric authentication, runtime integrity checks, and the threat model that makes mobile fundamentally different from server security.
Mobile security operates under a fundamentally different threat model than server security. On the server, you control the environment. On mobile, the device belongs to the user — or the attacker. The binary is downloadable, decompilable, and modifiable. The network is interceptable. The storage is accessible on rooted/jailbroken devices.
Mobile security is not about making attacks impossible. It is about making them expensive enough that attackers choose easier targets.
The Mobile Threat Model
What You Cannot Trust
- The device: May be rooted/jailbroken, running a modified OS
- The binary: Can be decompiled, patched, and redistributed
- The network: Can be intercepted, even with HTTPS
- Local storage: Accessible on rooted devices
- The runtime: Debuggers can attach, memory can be read
What You Must Protect
- User credentials: Tokens, passwords, biometric data
- API keys: Service keys, analytics tokens, payment credentials
- Business logic: Pricing algorithms, validation rules
- User data: PII, financial data, health data
Secure Storage
iOS Keychain
The iOS Keychain is hardware-backed encrypted storage:
import Security
func saveToKeychain(key: String, data: Data) -> Bool {
let query: [String: Any] = [
kSecClass as String: kSecClassGenericPassword,
kSecAttrAccount as String: key,
kSecValueData as String: data,
kSecAttrAccessible as String: kSecAttrAccessibleWhenUnlockedThisDeviceOnly
]
SecItemDelete(query as CFDictionary) // Remove existing
let status = SecItemAdd(query as CFDictionary, nil)
return status == errSecSuccess
}
Key kSecAttrAccessible values:
kSecAttrAccessibleWhenUnlockedThisDeviceOnly: Most secure, not backed upkSecAttrAccessibleAfterFirstUnlockThisDeviceOnly: Available in background
Android Keystore
val keyStore = KeyStore.getInstance("AndroidKeyStore")
keyStore.load(null)
val keyGenerator = KeyGenerator.getInstance(
KeyProperties.KEY_ALGORITHM_AES, "AndroidKeyStore"
)
keyGenerator.init(
KeyGenParameterSpec.Builder("secret_key",
KeyProperties.PURPOSE_ENCRYPT or KeyProperties.PURPOSE_DECRYPT)
.setBlockModes(KeyProperties.BLOCK_MODE_GCM)
.setEncryptionPaddings(KeyProperties.ENCRYPTION_PADDING_NONE)
.setUserAuthenticationRequired(true)
.setUserAuthenticationValidityDurationSeconds(300)
.build()
)
What NOT to Store Securely
Never store on the device:
- API secret keys (use a backend proxy)
- Encryption keys for server-side data
- Any credential that could be used to impersonate all users
Certificate Pinning
Standard HTTPS trusts any certificate signed by a trusted CA. Certificate pinning restricts trust to specific certificates or public keys:
iOS Implementation
// URLSession delegate for certificate pinning
func urlSession(_ session: URLSession, didReceive challenge: URLAuthenticationChallenge,
completionHandler: @escaping (URLSession.AuthChallengeDisposition, URLCredential?) -> Void) {
guard let serverTrust = challenge.protectionSpace.serverTrust,
let serverCert = SecTrustGetCertificateAtIndex(serverTrust, 0) else {
completionHandler(.cancelAuthenticationChallenge, nil)
return
}
let serverPublicKey = SecCertificateCopyKey(serverCert)
let pinnedPublicKey = loadPinnedPublicKey()
if serverPublicKey == pinnedPublicKey {
completionHandler(.useCredential, URLCredential(trust: serverTrust))
} else {
completionHandler(.cancelAuthenticationChallenge, nil)
}
}
Pin Public Keys, Not Certificates
Certificates expire and rotate. Public keys persist across certificate renewals. Pin the public key hash:
Pin-SHA256: "base64encodedSHA256ofSubjectPublicKeyInfo"
Certificate Pinning Risks
- App updates required: If you rotate keys without updating the pin set, the app breaks
- Backup pins: Always include at least one backup pin
- Emergency bypass: Have a mechanism to disable pinning remotely if a key rotation goes wrong
Biometric Authentication
// iOS: Face ID / Touch ID
let context = LAContext()
var error: NSError?
if context.canEvaluatePolicy(.deviceOwnerAuthenticationWithBiometrics, error: &error) {
context.evaluatePolicy(.deviceOwnerAuthenticationWithBiometrics,
localizedReason: "Authenticate to view your account") { success, error in
if success {
// Biometric authentication succeeded
unlockKeychain()
}
}
}
Biometric Best Practices
- Use biometrics to unlock a locally stored token, not as the sole authentication
- Fall back to PIN/password, not to “no authentication”
- Re-authenticate for sensitive actions (payment, data export)
- Do not store the biometric data — use OS-level APIs that handle this securely
Runtime Integrity Checks
Root/Jailbreak Detection
// Android: Basic root detection
fun isDeviceRooted(): Boolean {
val paths = arrayOf(
"/system/app/Superuser.apk",
"/system/xbin/su",
"/system/bin/su",
"/data/local/bin/su",
"/data/local/xbin/su"
)
return paths.any { File(it).exists() } ||
Build.TAGS?.contains("test-keys") == true
}
Important: Root detection is a speed bump, not a wall. Determined attackers bypass it. Use it as one signal among many, not as a single gate.
Tamper Detection
Verify the app binary has not been modified:
// iOS: App Store receipt validation
func verifyAppIntegrity() -> Bool {
guard let receiptURL = Bundle.main.appStoreReceiptURL,
FileManager.default.fileExists(atPath: receiptURL.path) else {
return false
}
// Validate receipt with Apple's servers
return validateReceipt(at: receiptURL)
}
API Security for Mobile
Token Management
Access Token: Short-lived (15-60 minutes), stored in memory
Refresh Token: Long-lived (30-90 days), stored in Keychain/Keystore
API Key: Embedded in binary (public, rate-limited, not a secret)
Request Signing
Prevent request tampering by signing API requests:
# HMAC-based request signing
import hmac
import hashlib
def sign_request(method, path, body, timestamp, secret):
message = f"{method}\n{path}\n{timestamp}\n{hashlib.sha256(body).hexdigest()}"
return hmac.new(secret.encode(), message.encode(), hashlib.sha256).hexdigest()
Server-Side Enforcement
The server should never trust the client:
- Validate all input server-side (prices, quantities, permissions)
- Rate limit per user and per device
- Log and alert on anomalous patterns (impossible travel, repeated failures)
Anti-Patterns
| Anti-Pattern | Risk | Fix |
|---|---|---|
| Hardcoded API secrets | Extracted from binary in minutes | Backend proxy or token exchange |
| Trusting client-side validation | Business logic bypass | Validate everything server-side |
| No certificate pinning | Man-in-the-middle attacks | Pin public keys with backup pins |
| Root detection as sole defense | Bypassed by frida/xposed | Layer multiple detection signals |
| Storing tokens in UserDefaults/SharedPreferences | Readable on compromised devices | Keychain/Keystore only |
Mobile security is defense in depth. No single technique stops a determined attacker. The combination of secure storage, certificate pinning, integrity checks, server-side validation, and monitoring makes attacks expensive enough to deter all but the most motivated adversaries.