Mobile App Security: iOS & Android Security Checklist
What is the mobile app security checklist (iOS and Android)?
TL;DR
- Bottom line: Follow the OWASP MASVS v2.0 eight-category checklist (Storage, Crypto, Auth, Network, Platform, Code, Resilience, Privacy) with platform-specific implementations using iOS Keychain/Android Keystore for secrets, TLS 1.2+ with certificate pinning for networking, and layered runtime protection.
- Key tool/command:
OWASP MASVS v2.0checklist +MobSF(Mobile Security Framework) for automated scanning. - Watch out for: Storing sensitive data in plaintext SharedPreferences (Android) or UserDefaults (iOS) -- the #1 finding in mobile penetration tests.
- Works with: iOS 15+ (Swift 5.5+), Android API 23+ (Kotlin 1.8+). OWASP MASVS v2.0 is platform-agnostic.
Constraints
- NEVER store secrets (API keys, tokens, passwords) in plaintext -- use iOS Keychain or Android Keystore
- ALWAYS enforce TLS 1.2+ for all network communication -- no cleartext HTTP traffic
- NEVER disable certificate validation in production builds -- test-only flags must be removed
- Biometric authentication MUST have a server-side fallback -- device-only auth is bypassable on rooted/jailbroken devices
- Code obfuscation is defense-in-depth, NOT a security boundary -- never rely on it as sole protection
- Root/jailbreak detection MUST be combined with runtime integrity checks -- detection alone is trivially bypassed
Quick Reference
OWASP MASVS v2.0 Security Checklist
| # | Category | Key Controls | iOS Implementation | Android Implementation |
|---|---|---|---|---|
| 1 | MASVS-STORAGE | Secure sensitive data at rest; no secrets in logs/backups/clipboard | Keychain with kSecAttrAccessibleWhenUnlockedThisDeviceOnly; Data Protection API | Android Keystore; EncryptedFile; exclude from backups via android:allowBackup="false" |
| 2 | MASVS-CRYPTO | Use approved algorithms; secure key management; no hardcoded keys | CommonCrypto/CryptoKit with AES-GCM; Secure Enclave for key storage | Android Keystore with setUserAuthenticationRequired(true); AES/GCM/NoPadding |
| 3 | MASVS-AUTH | Strong authentication; session management; biometric with fallback | LAContext with evaluatePolicy(.deviceOwnerAuthenticationWithBiometrics); server-side token validation | BiometricPrompt with setNegativeButtonText for fallback; server-side session binding |
| 4 | MASVS-NETWORK | TLS 1.2+ enforced; certificate pinning; no cleartext traffic | App Transport Security (ATS) enforced by default; URLSession delegate for pinning | Network Security Config XML; cleartextTrafficPermitted="false"; pin-set with backup pins |
| 5 | MASVS-PLATFORM | Secure IPC; WebView hardening; input validation from deep links | Universal Links validation; WKWebView (never UIWebView); URL scheme input sanitization | Intent filter validation; WebView.setJavaScriptEnabled(false) by default; deep link validation |
| 6 | MASVS-CODE | Dependency management; no debug code in production; input validation | Strip debug symbols; disable logging in release; dependency audit | ProGuard/R8 obfuscation; BuildConfig.DEBUG checks; dependency scanning |
| 7 | MASVS-RESILIENCE | Anti-tampering; anti-debugging; integrity verification | App Attest API (DeviceCheck framework); ptrace detection; code signing validation | Play Integrity API (replaces SafetyNet); signature verification; Debug.isDebuggerConnected() checks |
| 8 | MASVS-PRIVACY | Data minimization; consent management; anonymization | ATT (App Tracking Transparency) framework; Privacy Manifest; on-device processing | Privacy Sandbox APIs; runtime permissions; PURPOSE_ declarations in manifest |
Decision Tree
START: What is your app's risk profile?
├── Handles financial/health/government data?
│ ├── YES → Implement ALL 8 MASVS categories + certificate pinning + RASP + App Attest/Play Integrity
│ └── NO ↓
├── Handles user credentials or PII?
│ ├── YES → Implement MASVS-STORAGE + CRYPTO + AUTH + NETWORK + PLATFORM + CODE
│ └── NO ↓
├── Public data only, no user accounts?
│ ├── YES → Implement MASVS-NETWORK (TLS) + PLATFORM (WebView) + CODE (dependencies)
│ └── NO ↓
├── Which platform?
│ ├── iOS → Use Keychain + ATS + App Attest + CryptoKit
│ ├── Android → Use Keystore + Network Security Config + Play Integrity + Jetpack Security
│ └── Cross-platform → Apply native security on each side; DO NOT share crypto code
└── DEFAULT → Implement at minimum: MASVS-STORAGE + MASVS-NETWORK + MASVS-CODE
Step-by-Step Guide
1. Secure local data storage
Store all sensitive data (tokens, credentials, PII) in platform-provided secure storage. Never use plaintext files, SharedPreferences, or UserDefaults for secrets. [src1]
// iOS: Keychain with device-only access
import Security
func saveToKeychain(key: String, data: Data) -> OSStatus {
let query: [String: Any] = [
kSecClass as String: kSecClassGenericPassword,
kSecAttrAccount as String: key,
kSecValueData as String: data,
kSecAttrAccessible as String: kSecAttrAccessibleWhenUnlockedThisDeviceOnly
]
SecItemDelete(query as CFDictionary)
return SecItemAdd(query as CFDictionary, nil)
}
// Android: EncryptedSharedPreferences
val masterKey = MasterKey.Builder(context)
.setKeyScheme(MasterKey.KeyScheme.AES256_GCM).build()
val securePrefs = EncryptedSharedPreferences.create(
context, "secure_prefs", masterKey,
EncryptedSharedPreferences.PrefKeyEncryptionScheme.AES256_SIV,
EncryptedSharedPreferences.PrefValueEncryptionScheme.AES256_GCM
)
securePrefs.edit().putString("auth_token", token).apply()
Verify: Run MobSF static analysis -- no findings under "Insecure Data Storage" category.
2. Implement certificate pinning
Pin your server's public key (SPKI hash) to prevent MITM attacks even with compromised CAs. Always include a backup pin. [src4]
<!-- Android: res/xml/network_security_config.xml -->
<network-security-config>
<base-config cleartextTrafficPermitted="false" />
<domain-config>
<domain includeSubdomains="true">api.example.com</domain>
<pin-set expiration="2027-01-01">
<pin digest="SHA-256">AAAA...primary-pin...AAAA=</pin>
<pin digest="SHA-256">BBBB...backup-pin....BBBB=</pin>
</pin-set>
</domain-config>
</network-security-config>
Verify: Use a proxy tool (Charles/mitmproxy) -- requests should fail when proxy's cert is injected.
3. Implement biometric authentication with server binding
Biometric auth must be tied to a server-side session, not just a local gate. A jailbroken device can bypass local-only biometric checks. [src3]
// iOS: LAContext biometric + Keychain-bound token
import LocalAuthentication
let context = LAContext()
if context.canEvaluatePolicy(.deviceOwnerAuthenticationWithBiometrics, error: nil) {
context.evaluatePolicy(
.deviceOwnerAuthenticationWithBiometrics,
localizedReason: "Authenticate to access your account"
) { success, error in
if success {
let token = loadFromKeychain(key: "auth_token")
// Send token to server for validation
}
}
}
Verify: Attempt to access protected resources without biometric -- should be denied both locally and server-side.
4. Harden WebViews
WebViews are a major attack surface. Disable JavaScript unless required, validate all URLs, and never expose native bridges to untrusted content. [src2]
// Android: Secure WebView configuration
val webView = WebView(context)
webView.settings.apply {
javaScriptEnabled = false // Enable ONLY if required
allowFileAccess = false // Prevent file:// access
allowContentAccess = false // Prevent content:// access
domStorageEnabled = false
setGeolocationEnabled(false)
}
Verify: Attempt to load javascript:alert(1) or file:///etc/passwd -- both should be blocked.
5. Enable runtime integrity checks
Detect tampering, debugging, and compromised environments. Use platform attestation APIs for server-side verification. [src7]
// Android: Play Integrity API
val integrityManager = IntegrityManagerFactory.create(context)
val request = IntegrityTokenRequest.builder()
.setNonce(generateNonce()).build()
integrityManager.requestIntegrityToken(request)
.addOnSuccessListener { response ->
val token = response.integrityToken()
// Send to YOUR server for verification
}
Verify: Run on rooted/jailbroken device or emulator -- integrity check should fail.
Code Examples
Swift: Secure Keychain Storage with Biometric Access Control
import Security
import LocalAuthentication
// Input: Secret string to store securely
// Output: OSStatus indicating success/failure
func storeSecretWithBiometric(key: String, secret: String) -> OSStatus {
guard let data = secret.data(using: .utf8) else { return errSecParam }
let access = SecAccessControlCreateWithFlags(
nil,
kSecAttrAccessibleWhenUnlockedThisDeviceOnly,
.biometryCurrentSet,
nil
)!
let query: [String: Any] = [
kSecClass as String: kSecClassGenericPassword,
kSecAttrAccount as String: key,
kSecValueData as String: data,
kSecAttrAccessControl as String: access
]
SecItemDelete(query as CFDictionary)
return SecItemAdd(query as CFDictionary, nil)
}
Kotlin: Android Keystore AES-GCM Encryption
import android.security.keystore.KeyGenParameterSpec
import android.security.keystore.KeyProperties
import javax.crypto.Cipher
import javax.crypto.KeyGenerator
// Input: Plaintext byte array
// Output: Encrypted data + IV pair
object SecureStorage {
private const val KEY_ALIAS = "app_secret_key"
fun generateKey() {
val gen = KeyGenerator.getInstance(
KeyProperties.KEY_ALGORITHM_AES, "AndroidKeyStore")
gen.init(KeyGenParameterSpec.Builder(KEY_ALIAS,
KeyProperties.PURPOSE_ENCRYPT or KeyProperties.PURPOSE_DECRYPT)
.setBlockModes(KeyProperties.BLOCK_MODE_GCM)
.setEncryptionPaddings(KeyProperties.ENCRYPTION_PADDING_NONE)
.setUserAuthenticationRequired(true)
.setUserAuthenticationParameters(300,
KeyProperties.AUTH_BIOMETRIC_STRONG)
.build())
gen.generateKey()
}
}
Anti-Patterns
Wrong: Storing tokens in plaintext SharedPreferences
// BAD -- plaintext storage readable on rooted devices
val prefs = context.getSharedPreferences("app_prefs", Context.MODE_PRIVATE)
prefs.edit().putString("auth_token", "eyJhbGciOiJIUzI1NiIs...").apply()
// Any app with root access can read /data/data/com.app/shared_prefs/
Correct: Use EncryptedSharedPreferences or Keystore
// GOOD -- encrypted at rest with Keystore-backed key
val masterKey = MasterKey.Builder(context)
.setKeyScheme(MasterKey.KeyScheme.AES256_GCM).build()
val securePrefs = EncryptedSharedPreferences.create(
context, "secure_prefs", masterKey,
EncryptedSharedPreferences.PrefKeyEncryptionScheme.AES256_SIV,
EncryptedSharedPreferences.PrefValueEncryptionScheme.AES256_GCM
)
securePrefs.edit().putString("auth_token", token).apply()
Wrong: Disabling SSL verification for testing (left in production)
// BAD -- disables ALL certificate validation
class InsecureDelegate: NSObject, URLSessionDelegate {
func urlSession(_ session: URLSession,
didReceive challenge: URLAuthenticationChallenge,
completionHandler: @escaping (URLSession.AuthChallengeDisposition, URLCredential?) -> Void) {
completionHandler(.useCredential,
URLCredential(trust: challenge.protectionSpace.serverTrust!))
}
}
Correct: Implement proper certificate pinning
// GOOD -- validates server certificate against pinned key hash
class PinnedDelegate: NSObject, URLSessionDelegate {
func urlSession(_ session: URLSession,
didReceive challenge: URLAuthenticationChallenge,
completionHandler: @escaping (URLSession.AuthChallengeDisposition, URLCredential?) -> Void) {
guard let trust = challenge.protectionSpace.serverTrust,
SecTrustEvaluateWithError(trust, nil),
validatePin(trust: trust) else {
completionHandler(.cancelAuthenticationChallenge, nil)
return
}
completionHandler(.useCredential, URLCredential(trust: trust))
}
}
Wrong: Logging sensitive data
// BAD -- tokens and PII visible in logcat
Log.d("Auth", "User token: $authToken")
Log.d("Payment", "Card number: ${card.number}")
Correct: Conditional logging with sanitization
// GOOD -- no sensitive data; disabled in release
if (BuildConfig.DEBUG) {
Log.d("Auth", "Token received: [REDACTED]")
}
// ProGuard rule: -assumenosideeffects class android.util.Log { *; }
Wrong: Hardcoding API keys in source code
// BAD -- extractable via strings command on binary
let apiKey = "sk_live_abc123xyz789"
Correct: Retrieve keys from secure storage at runtime
// GOOD -- key stored in Keychain, never in source code
guard let apiKeyData = loadFromKeychain(key: "api_key"),
let apiKey = String(data: apiKeyData, encoding: .utf8) else {
fatalError("API key not provisioned")
}
Common Pitfalls
- Backup exposure: Android auto-backup includes SharedPreferences by default. Fix: Set
android:allowBackup="false"or useandroid:fullBackupContentto exclude sensitive files. [src1] - Clipboard data leakage: Sensitive data copied to clipboard is accessible to all apps. Fix: Use
UIPasteboardwith.localOnly: trueand expiry on iOS;ClipboardManagerwith expiry on Android. [src2] - Screenshot/screen recording exposure: iOS and Android capture app screens for task switcher. Fix: Add a blur overlay in
applicationWillResignActive(iOS) or setFLAG_SECUREon the window (Android). [src2] - Insecure deep link handling: Unvalidated deep links allow injection attacks. Fix: Validate all URI parameters; use Universal Links (iOS) / App Links (Android) with domain verification. [src1]
- Certificate pinning without backup pins: App becomes unusable after server certificate rotation. Fix: Always include at least 2 pins (current + next rotation key) with an expiration date. [src4]
- Ignoring Keychain accessibility classes: Using
kSecAttrAccessibleAlwaysmakes Keychain data available when device is locked. Fix: UsekSecAttrAccessibleWhenUnlockedThisDeviceOnlyfor sensitive data. [src3] - Hardcoded symmetric keys: Easily extractable with
stringsor disassemblers. Fix: Generate keys in Keystore/Keychain or derive via HKDF from server material. [src6] - Missing ProGuard/R8 rules: Default config may not obfuscate security-critical classes. Fix: Verify obfuscation with
apktool d app.apkand check class/method names. [src7]
Diagnostic Commands
# Static analysis with MobSF (Docker)
docker run -it --rm -p 8000:8000 opensecurity/mobile-security-framework-mobsf:latest
# Extract SPKI hash for certificate pinning
openssl s_client -connect api.example.com:443 2>/dev/null | \
openssl x509 -pubkey -noout | \
openssl pkey -pubin -outform DER | \
openssl dgst -sha256 -binary | \
openssl enc -base64
# Check APK for plaintext secrets
apktool d app.apk -o decoded && grep -rn "api_key\|secret\|password\|token" decoded/
# Verify iOS binary for hardcoded strings
strings MyApp.app/MyApp | grep -i "key\|secret\|password\|token"
# Check Android obfuscation
jadx -d output app.apk && grep -rn "class.*Activity" output/
# Verify network security config is applied
adb shell dumpsys package com.example.app | grep networkSecurityConfig
Version History & Compatibility
| Standard/Tool | Version | Status | Key Changes |
|---|---|---|---|
| OWASP MASVS | v2.1.0 | Current (2024) | Added MASVS-PRIVACY; removed L1/L2/R levels; MAS Testing Profiles |
| OWASP MASVS | v1.5.0 | Deprecated | Three verification levels (L1, L2, R); MSTG companion |
| Android Jetpack Security | 1.1.0-alpha06 | Current | EncryptedSharedPreferences + EncryptedFile |
| Play Integrity API | 1.3 | Current (2025) | Replaces SafetyNet Attestation (deprecated 2024) |
| Apple App Attest | iOS 14+ | Current | Device attestation; replaces DeviceCheck for assertions |
| Apple ATS | iOS 9+ | Enforced | TLS 1.2+ required by default |
| CryptoKit | iOS 13+ | Current | Modern Swift crypto API (AES-GCM, SHA-256, P-256) |
When to Use / When Not to Use
| Use When | Don't Use When | Use Instead |
|---|---|---|
| App handles user credentials, PII, or payment data | Building a static content app with no user data | Basic HTTPS enforcement only |
| App communicates with backend APIs | App is entirely offline with no network calls | Focus on MASVS-STORAGE only |
| App processes financial or health data | Building an internal prototype/demo | Simplified security for development |
| App is distributed via public app stores | App is only used on managed/MDM devices | Enterprise MDM security policies |
| Regulatory compliance required (GDPR, HIPAA, PCI DSS) | No regulatory requirements apply | Risk-based subset of MASVS |
Important Caveats
- OWASP MASVS v2.0+ no longer defines verification "levels" (L1/L2/R) -- instead use MAS Testing Profiles to determine which controls apply to your risk level
- Android EncryptedSharedPreferences requires API 23+ (Android 6.0) and the AndroidX Security library; it has been deprecated in favor of direct Keystore usage but remains functional
- Certificate pinning can lock users out of your app if pins expire and the app is not updated -- always set pin expiration dates and include backup pins
- Root/jailbreak detection is an arms race -- determined attackers can bypass any detection method; it should be one layer in a defense-in-depth strategy
- iOS Keychain items may persist across app reinstalls unless
kSecAttrAccessibleWhenPasscodeSetThisDeviceOnlyis used -- this can be a privacy concern - Cross-platform frameworks (React Native, Flutter) add additional attack surface through their bridge layers -- apply native security controls on each platform side