Mobile App Security Best Practices: Pinning, Secure Storage, and Code Protection
Mobile application security requires a multi-layered approach. From network communication to local data storage, attackers probe every surface. This guide covers production-grade security measures for both iOS and Android applications.
Network Security: Certificate Pinning
Certificate pinning prevents man-in-the-middle attacks by validating the server's certificate against a known good value.
iOS Certificate Pinning with URLSession
class PinnedURLSessionDelegate: NSObject, URLSessionDelegate {
private let pinnedCertificates: [Data]
init(certificates: [Data]) {
self.pinnedCertificates = certificates
}
func urlSession(
_ session: URLSession,
didReceive challenge: URLAuthenticationChallenge,
completionHandler: @escaping (URLSession.AuthChallengeDisposition, URLCredential?) -> Void
) {
guard challenge.protectionSpace.authenticationMethod == NSURLAuthenticationMethodServerTrust,
let serverTrust = challenge.protectionSpace.serverTrust else {
completionHandler(.cancelAuthenticationChallenge, nil)
return
}
// Validate the certificate chain
var error: CFError?
guard SecTrustEvaluateWithError(serverTrust, &error) else {
completionHandler(.cancelAuthenticationChallenge, nil)
return
}
// Get the server certificate
guard let serverCertificate = SecTrustGetCertificateAtIndex(serverTrust, 0) else {
completionHandler(.cancelAuthenticationChallenge, nil)
return
}
let serverCertData = SecCertificateCopyData(serverCertificate) as Data
// Check against pinned certificates
if pinnedCertificates.contains(serverCertData) {
completionHandler(.useCredential, URLCredential(trust: serverTrust))
} else {
completionHandler(.cancelAuthenticationChallenge, nil)
// Report potential MITM attack
SecurityLogger.report(event: .certificatePinningFailure)
}
}
}
// Usage
let pinnedDelegate = PinnedURLSessionDelegate(
certificates: [loadCertificateData(named: "api.example.com")]
)
let session = URLSession(
configuration: .default,
delegate: pinnedDelegate,
delegateQueue: nil
)
Android Certificate Pinning with OkHttp
import okhttp3.CertificatePinner
import okhttp3.OkHttpClient
val certificatePinner = CertificatePinner.Builder()
.add("api.example.com", "sha256/AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=")
.add("api.example.com", "sha256/BBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB=") // backup
.build()
val client = OkHttpClient.Builder()
.certificatePinner(certificatePinner)
.connectTimeout(30, TimeUnit.SECONDS)
.readTimeout(30, TimeUnit.SECONDS)
.build()
Network Security Config (Android)
<!-- res/xml/network_security_config.xml -->
<?xml version="1.0" encoding="utf-8"?>
<network-security-config>
<domain-config cleartextTrafficPermitted="false">
<domain includeSubdomains="true">api.example.com</domain>
<pin-set expiration="2027-01-01">
<pin digest="SHA-256">AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=</pin>
<!-- Backup pin -->
<pin digest="SHA-256">BBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB=</pin>
</pin-set>
<trust-anchors>
<certificates src="system" />
</trust-anchors>
</domain-config>
</network-security-config>
Secure Storage
iOS Keychain
import Security
class KeychainManager {
static func save(key: String, value: String) throws {
let data = value.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.saveFailed(status)
}
}
static func load(key: String) throws -> String {
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,
let data = result as? Data,
let value = String(data: data, encoding: .utf8) else {
throw KeychainError.loadFailed(status)
}
return value
}
static func delete(key: String) {
let query: [String: Any] = [
kSecClass as String: kSecClassGenericPassword,
kSecAttrAccount as String: key,
]
SecItemDelete(query as CFDictionary)
}
}
Android EncryptedSharedPreferences
import androidx.security.crypto.EncryptedSharedPreferences
import androidx.security.crypto.MasterKey
class SecureStorage(context: Context) {
private val masterKey = MasterKey.Builder(context)
.setKeyScheme(MasterKey.KeyScheme.AES256_GCM)
.build()
private val sharedPreferences = EncryptedSharedPreferences.create(
context,
"secure_prefs",
masterKey,
EncryptedSharedPreferences.PrefKeyEncryptionScheme.AES256_SIV,
EncryptedSharedPreferences.PrefValueEncryptionScheme.AES256_GCM,
)
fun saveToken(token: String) {
sharedPreferences.edit()
.putString("auth_token", token)
.apply()
}
fun getToken(): String? = sharedPreferences.getString("auth_token", null)
fun clearToken() {
sharedPreferences.edit().remove("auth_token").apply()
}
}
Jailbreak and Root Detection
iOS Jailbreak Detection
class IntegrityChecker {
static func isDeviceJailbroken() -> Bool {
// Check for jailbreak files
let jailbreakPaths = [
"/Applications/Cydia.app",
"/Library/MobileSubstrate/MobileSubstrate.dylib",
"/bin/bash",
"/usr/sbin/sshd",
"/etc/apt",
"/private/var/lib/apt/",
]
for path in jailbreakPaths {
if FileManager.default.fileExists(atPath: path) {
return true
}
}
// Try to write to system directory
let testPath = "/private/jailbreak_test_\(UUID().uuidString)"
do {
try "test".write(toFile: testPath, atomically: true, encoding: .utf8)
try FileManager.default.removeItem(atPath: testPath)
return true
} catch {}
// Check for sandbox violation
if let _ = try? String(contentsOfFile: "/etc/passwd") {
return true
}
return false
}
}
Android Root Detection
object RootDetector {
fun isDeviceRooted(): Boolean {
return checkSuBinary() || checkBuildTags() || checkRootApps()
}
private fun checkSuBinary(): Boolean {
val paths = arrayOf(
"/system/bin/su", "/system/xbin/su",
"/sbin/su", "/data/local/xbin/su",
"/data/local/bin/su", "/data/local/su",
)
return paths.any { File(it).exists() }
}
private fun checkBuildTags(): Boolean {
val buildTags = Build.TAGS
return buildTags != null && buildTags.contains("test-keys")
}
private fun checkRootApps(): Boolean {
val rootApps = arrayOf(
"com.noshufou.android.su",
"com.thirdparty.superuser",
"eu.chainfire.supersu",
"com.koushikdutta.superuser",
"com.zachspong.temprootremovejb",
)
val pm = appContext.packageManager
return rootApps.any { packageName ->
try {
pm.getPackageInfo(packageName, 0)
true
} catch (e: PackageManager.NameNotFoundException) {
false
}
}
}
}
API Security
JWT Token Handling
import * as SecureStore from 'expo-secure-store';
import jwtDecode from 'jwt-decode';
class TokenManager {
private static ACCESS_TOKEN_KEY = 'access_token';
private static REFRESH_TOKEN_KEY = 'refresh_token';
static async saveTokens(accessToken: string, refreshToken: string) {
await Promise.all([
SecureStore.setItemAsync(this.ACCESS_TOKEN_KEY, accessToken),
SecureStore.setItemAsync(this.REFRESH_TOKEN_KEY, refreshToken),
]);
}
static async getAccessToken(): Promise<string | null> {
const token = await SecureStore.getItemAsync(this.ACCESS_TOKEN_KEY);
if (!token) return null;
const decoded = jwtDecode<{ exp: number }>(token);
const isExpired = decoded.exp * 1000 < Date.now() + 30000; // 30s buffer
if (isExpired) {
return this.refreshAccessToken();
}
return token;
}
static async refreshAccessToken(): Promise<string | null> {
const refreshToken = await SecureStore.getItemAsync(this.REFRESH_TOKEN_KEY);
if (!refreshToken) return null;
try {
const response = await fetch('/api/auth/refresh', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ refreshToken }),
});
const { accessToken, newRefreshToken } = await response.json();
await this.saveTokens(accessToken, newRefreshToken);
return accessToken;
} catch {
await this.clearTokens();
return null;
}
}
static async clearTokens() {
await Promise.all([
SecureStore.deleteItemAsync(this.ACCESS_TOKEN_KEY),
SecureStore.deleteItemAsync(this.REFRESH_TOKEN_KEY),
]);
}
}
Request Signing
import CryptoJS from 'crypto-js';
function signRequest(
method: string,
path: string,
body: object | null,
secretKey: string
): Record<string, string> {
const timestamp = Date.now().toString();
const nonce = Math.random().toString(36).substring(2);
const bodyHash = body
? CryptoJS.SHA256(JSON.stringify(body)).toString()
: '';
const message = `${method}\n${path}\n${timestamp}\n${nonce}\n${bodyHash}`;
const signature = CryptoJS.HmacSHA256(message, secretKey).toString();
return {
'X-Timestamp': timestamp,
'X-Nonce': nonce,
'X-Signature': signature,
};
}
Code Obfuscation
Android ProGuard/R8
# proguard-rules.pro
-optimizationpasses 5
-dontusemixedcaseclassnames
-dontskipnonpubliclibraryclasses
-dontpreverify
-verbose
# Keep model classes
-keep class com.example.app.models.** { *; }
# Obfuscate everything else
-keepattributes Signature
-keepattributes *Annotation*
# Remove logging in release
-assumenosideeffects class android.util.Log {
public static *** d(...);
public static *** v(...);
public static *** i(...);
}
Conclusion
Mobile app security is not a single feature but a holistic discipline. Implement certificate pinning to prevent network interception. Use platform-native secure storage for sensitive data. Detect compromised devices and respond appropriately. Sign and validate API requests. Apply code obfuscation in release builds. Conduct regular security audits and penetration testing. Combined, these layers create defense-in-depth that protects both your users and your business from the most common mobile attack vectors.