Mobile apps handle sensitive data — payment credentials, health records, personal messages, location history. Yet many apps ship with security gaps that a determined attacker can exploit in minutes using freely available tools. This guide covers the OWASP Mobile Top 10 defenses, practical implementation patterns for both iOS and Android, and the security mistakes we see most often during code reviews at Pillai Infotech.
📋 Table of Contents
OWASP Mobile Top 10 (2024)
The OWASP Mobile Top 10 is the industry-standard list of mobile security risks. Here's the current list with the defenses that matter most:
| # | Risk | Primary Defense |
|---|---|---|
| M1 | Improper Credential Usage | Keychain/Keystore, never hardcode secrets |
| M2 | Inadequate Supply Chain Security | Dependency scanning, lock files, verified sources |
| M3 | Insecure Authentication | Server-side validation, biometric + token |
| M4 | Insufficient Input/Output Validation | Validate all inputs, encode outputs |
| M5 | Insecure Communication | TLS 1.3, certificate pinning |
| M6 | Inadequate Privacy Controls | Minimize data collection, encrypt at rest |
| M7 | Insufficient Binary Protections | Obfuscation, tamper detection |
| M8 | Security Misconfiguration | Disable debug, remove test credentials |
| M9 | Insecure Data Storage | Encrypted storage, no sensitive data in logs |
| M10 | Insufficient Cryptography | Use platform crypto APIs, AES-256-GCM |
Secure Data Storage
The most common mistake we see: storing sensitive data in plaintext using SharedPreferences (Android) or UserDefaults (iOS). Both are trivially readable on rooted/jailbroken devices. Use the platform's secure storage APIs instead.
Android: EncryptedSharedPreferences + Keystore
// Android — secure token storage
import androidx.security.crypto.EncryptedSharedPreferences
import androidx.security.crypto.MasterKey
class SecureTokenStore(context: Context) {
private val masterKey = MasterKey.Builder(context)
.setKeyScheme(MasterKey.KeyScheme.AES256_GCM)
.build()
private val prefs = EncryptedSharedPreferences.create(
context,
"secure_prefs",
masterKey,
EncryptedSharedPreferences.PrefKeyEncryptionScheme.AES256_SIV,
EncryptedSharedPreferences.PrefValueEncryptionScheme.AES256_GCM
)
fun saveToken(token: String) {
prefs.edit().putString("auth_token", token).apply()
}
fun getToken(): String? = prefs.getString("auth_token", null)
fun clearToken() {
prefs.edit().remove("auth_token").apply()
}
}
iOS: Keychain Services
// iOS — Keychain wrapper for secure storage
import Security
struct KeychainHelper {
static func save(key: String, data: Data) throws {
let query: [String: Any] = [
kSecClass as String: kSecClassGenericPassword,
kSecAttrAccount as String: key,
kSecValueData as String: data,
kSecAttrAccessible as String: kSecAttrAccessibleAfterFirstUnlockThisDeviceOnly
]
SecItemDelete(query as CFDictionary) // Remove existing
let status = SecItemAdd(query as CFDictionary, nil)
guard status == errSecSuccess else {
throw KeychainError.saveFailed(status)
}
}
static func load(key: String) throws -> Data? {
let query: [String: Any] = [
kSecClass as String: kSecClassGenericPassword,
kSecAttrAccount as String: key,
kSecReturnData as String: true,
kSecMatchLimit as String: kSecMatchLimitOne
]
var result: AnyObject?
let status = SecItemCopyMatching(query as CFDictionary, &result)
guard status == errSecSuccess else { return nil }
return result as? Data
}
}
Network Security and Certificate Pinning
TLS alone isn't enough. A man-in-the-middle attack using a rogue CA certificate (trivially installable on a rooted device) can intercept all HTTPS traffic. Certificate pinning ensures your app only trusts your server's certificate.
Android: Network Security Config
<!-- res/xml/network_security_config.xml -->
<network-security-config>
<domain-config cleartextTrafficPermitted="false">
<domain includeSubdomains="true">api.yourapp.com</domain>
<pin-set expiration="2027-01-01">
<!-- Pin the intermediate CA certificate (not leaf) -->
<pin digest="SHA-256">YLh1dUR9y6Kja30RrAn7JKnbQG/uEtLMkBgFF2Fuihg=</pin>
<!-- Backup pin (different CA) for rotation -->
<pin digest="SHA-256">sRHdihwgkaib1P1gN7akqREHQMk5nOSeltDep3DcZhA=</pin>
</pin-set>
</domain-config>
</network-security-config>
iOS: URLSession Pinning
// iOS — Certificate pinning with URLSessionDelegate
class PinnedSessionDelegate: NSObject, URLSessionDelegate {
private let pinnedHashes: Set<String> = [
"YLh1dUR9y6Kja30RrAn7JKnbQG/uEtLMkBgFF2Fuihg=",
"sRHdihwgkaib1P1gN7akqREHQMk5nOSeltDep3DcZhA="
]
func urlSession(
_ session: URLSession,
didReceive challenge: URLAuthenticationChallenge
) async -> (URLSession.AuthChallengeDisposition, URLCredential?) {
guard let serverTrust = challenge.protectionSpace.serverTrust,
let certificate = SecTrustCopyCertificateChain(serverTrust)?.first
else {
return (.cancelAuthenticationChallenge, nil)
}
let serverHash = sha256Hash(of: SecCertificateCopyPublicKey(certificate)!)
if pinnedHashes.contains(serverHash) {
return (.useCredential, URLCredential(trust: serverTrust))
}
return (.cancelAuthenticationChallenge, nil)
}
}
Always pin the intermediate CA certificate, not the leaf. Leaf certificates rotate annually; intermediate CAs rotate much less frequently. Include a backup pin from a different CA so certificate rotation doesn't break your app.
Authentication and Biometrics
Modern mobile auth combines short-lived tokens with biometric verification for a balance of security and convenience.
Secure Token Architecture
- Access tokens: Short-lived (15-30 minutes), stored in memory only, sent with every API request
- Refresh tokens: Long-lived (30-90 days), stored in Keychain/Keystore, used only to get new access tokens
- Token rotation: Every refresh request returns a new refresh token and invalidates the old one. If a stolen refresh token is used, both sessions are terminated
Biometric Authentication
// Android — BiometricPrompt with Keystore-backed crypto
class BiometricAuthManager(private val activity: FragmentActivity) {
fun authenticate(onSuccess: (BiometricPrompt.CryptoObject) -> Unit) {
val promptInfo = BiometricPrompt.PromptInfo.Builder()
.setTitle("Verify your identity")
.setSubtitle("Use fingerprint or face to continue")
.setNegativeButtonText("Use PIN instead")
.setAllowedAuthenticators(BiometricManager.Authenticators.BIOMETRIC_STRONG)
.build()
val biometricPrompt = BiometricPrompt(
activity,
ContextCompat.getMainExecutor(activity),
object : BiometricPrompt.AuthenticationCallback() {
override fun onAuthenticationSucceeded(result: BiometricPrompt.AuthenticationResult) {
result.cryptoObject?.let { onSuccess(it) }
}
override fun onAuthenticationFailed() {
// Biometric didn't match — prompt stays open for retry
}
override fun onAuthenticationError(errorCode: Int, errString: CharSequence) {
// Fatal error — fallback to PIN/password
}
}
)
// Use Keystore-backed cipher for actual cryptographic guarantee
val cipher = getCipherForEncryption() // AES/GCM with Keystore key
biometricPrompt.authenticate(promptInfo, BiometricPrompt.CryptoObject(cipher))
}
}
CryptoObject, biometric auth is just a UI gate — an attacker can bypass it by hooking the callback. With CryptoObject, the biometric verification is tied to a Keystore-backed cryptographic operation that can't be faked.
API Security
Your API is only as secure as the weakest client calling it. Assume every request could be crafted by an attacker, not just your app.
Essential API Defenses
- Validate everything server-side: Never trust client-side validation alone. Check permissions, validate inputs, enforce rate limits on the server
- Use short-lived JWTs: Sign tokens with RS256 (asymmetric). Include
exp,iat,audclaims. Validate all claims server-side - Rate limiting: Per-user and per-IP limits. Tighter limits on auth endpoints (5 attempts/minute) than read endpoints
- Request signing: For high-security apps, sign each request with an HMAC derived from a per-session key. Prevents request tampering even if TLS is compromised
- App attestation: Use Google's Play Integrity API and Apple's App Attest to verify requests come from your genuine app on a non-tampered device
// Request signing — HMAC per request
fun signRequest(
method: String,
path: String,
body: String?,
sessionKey: ByteArray
): String {
val timestamp = System.currentTimeMillis() / 1000
val nonce = UUID.randomUUID().toString()
val payload = buildString {
append("$method\n")
append("$path\n")
append("$timestamp\n")
append("$nonce\n")
append(body?.sha256() ?: "")
}
val mac = Mac.getInstance("HmacSHA256")
mac.init(SecretKeySpec(sessionKey, "HmacSHA256"))
val signature = Base64.encodeToString(mac.doFinal(payload.toByteArray()), Base64.NO_WRAP)
return "ts=$timestamp,nonce=$nonce,sig=$signature"
}
Code Protection and Obfuscation
Any app installed on a device can be reverse-engineered. The goal isn't to make it impossible — it's to make it expensive enough that attackers move to easier targets.
| Protection | Android | iOS |
|---|---|---|
| Code obfuscation | R8/ProGuard (built-in) | Swift compiler strips symbols by default |
| String encryption | DexGuard or custom encryption | Computed properties, not string literals |
| Root/jailbreak detection | Play Integrity API + custom checks | App Attest + file-system checks |
| Tamper detection | APK signature verification at runtime | Code signing + App Attest |
| Debug detection | Check isDebuggable, detect Frida | Check sysctl for debugger attachment |
// Android — R8 ProGuard rules for security
// proguard-rules.pro
# Obfuscate everything by default
-dontoptimize
-keepattributes SourceFile,LineNumberTable # For crash reports
# Keep only what's needed
-keep class com.yourapp.api.models.** { *; } # API models (serialization)
-keep class * extends androidx.lifecycle.ViewModel # ViewModels
# Encrypt strings containing API endpoints
# (Use DexGuard for production, or move to BuildConfig/server)
// Android — basic root detection
fun isDeviceRooted(): Boolean {
val rootIndicators = listOf(
"/system/app/Superuser.apk",
"/system/xbin/su",
"/system/bin/su",
"/sbin/su",
"/data/local/xbin/su"
)
return rootIndicators.any { File(it).exists() } ||
Build.TAGS?.contains("test-keys") == true
}
Runtime Protection
Beyond build-time protections, your app should defend itself at runtime:
- Screenshot prevention: Add
FLAG_SECURE(Android) or implementUIApplicationDelegatemethods (iOS) on screens showing sensitive data — banking, health records, auth codes - Clipboard clearing: Clear the clipboard after a timeout when the user copies sensitive data like OTPs or account numbers
- Background snapshot protection: Both platforms take screenshots for the app switcher. Overlay a blur or placeholder on sensitive screens when the app enters the background
- Memory protection: Zero out sensitive data (passwords, tokens) from memory immediately after use. Don't keep them in String objects (which are immutable and may persist in memory)
- Logging discipline: Never log tokens, passwords, PII, or API keys. Use a logging wrapper that strips sensitive fields in production builds
// Android — screenshot prevention + background blur
class SecureActivity : AppCompatActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
// Prevent screenshots and screen recording
window.setFlags(
WindowManager.LayoutParams.FLAG_SECURE,
WindowManager.LayoutParams.FLAG_SECURE
)
}
}
// iOS — background snapshot protection
class SceneDelegate: UIResponder, UIWindowSceneDelegate {
private var blurView: UIVisualEffectView?
func sceneWillResignActive(_ scene: UIScene) {
// Add blur when entering background
let blur = UIBlurEffect(style: .light)
blurView = UIVisualEffectView(effect: blur)
blurView?.frame = window?.bounds ?? .zero
window?.addSubview(blurView!)
}
func sceneDidBecomeActive(_ scene: UIScene) {
blurView?.removeFromSuperview()
blurView = nil
}
}
Security Checklist
Use this checklist before every release. At Pillai Infotech, we run through this during every mobile app security review:
Pre-Release Security Checklist
- ☐ No hardcoded API keys, secrets, or credentials in source code
- ☐ Sensitive data stored in Keychain (iOS) / EncryptedSharedPreferences (Android)
- ☐ TLS 1.2+ enforced, cleartext traffic disabled
- ☐ Certificate pinning implemented with backup pins
- ☐ Auth tokens are short-lived with refresh rotation
- ☐ Biometric auth uses CryptoObject (not just UI gate)
- ☐ All inputs validated server-side (not just client-side)
- ☐ R8/ProGuard obfuscation enabled (Android release builds)
- ☐ Debug logging disabled in production builds
- ☐ No sensitive data in log output (grep for tokens, passwords, PII)
- ☐ Screenshot prevention on sensitive screens
- ☐ Background snapshot blurred for sensitive views
- ☐ Root/jailbreak detection implemented (at minimum, warning)
- ☐ Dependencies scanned for known vulnerabilities
- ☐ App Attest (iOS) / Play Integrity (Android) for API verification
- ☐ Deep links validated — no open redirect vulnerabilities
Frequently Asked Questions
Should I block rooted/jailbroken devices?
Depends on your app. Banking and healthcare apps should at minimum warn users and disable sensitive features. For general apps, detection is more useful for adjusting trust levels (e.g., requiring re-authentication) than outright blocking, which frustrates legitimate power users.
Is certificate pinning worth the operational complexity?
Yes, for apps handling financial data, healthcare records, or authentication flows. The key is pinning intermediate CA certificates (not leaf) and always having backup pins. Without pinning, any compromised CA can intercept your traffic.
How do I handle API keys for third-party services?
Never embed them in the app binary — they will be extracted. Proxy third-party API calls through your own backend server. The mobile app authenticates with your server; your server makes the third-party call with the API key stored server-side.
What about React Native and Flutter security?
Cross-platform frameworks face the same security challenges plus additional ones — JavaScript bundles in React Native are easier to reverse-engineer than compiled code. Apply all the same defenses, plus Hermes bytecode compilation (RN) and code obfuscation (Flutter).
How often should I do security audits?
At minimum: before initial launch, after major features, and annually. For apps handling sensitive data, quarterly penetration testing is recommended. Automated dependency scanning should run on every CI build.
What are the most common security mistakes you see?
Logging sensitive data in production, storing tokens in plaintext SharedPreferences/UserDefaults, not validating deep link parameters, using biometrics as a UI-only gate without CryptoObject, and trusting client-side validation without server-side checks.
Pillai Infotech LLP
We build secure mobile apps and conduct security reviews for iOS and Android applications. Get a security assessment for your mobile app.