iOS App Security: Secure Storage, Biometrics, and Transport Security
A comprehensive guide to hardening iOS applications — covering Keychain usage, App Transport Security, certificate pinning, biometric authentication, jailbreak detection, and binary protections aligned with OWASP Mobile Top 10.
Building a secure iOS application requires deliberate effort at every layer of the stack. The iOS platform provides an impressive set of security primitives — but only if you use them correctly. Misconfiguration, insecure defaults, and misunderstanding of the trust model are responsible for the majority of mobile security incidents. This guide walks through the critical controls every iOS developer should implement.
Never Use UserDefaults for Sensitive Data
The first mistake many developers make is storing tokens, credentials, or personally identifiable information in UserDefaults. UserDefaults is a plain property list stored on disk in /Library/Preferences/. On a jailbroken device — or when an attacker has physical access — this data is trivially readable.
The Keychain is the right tool. The iOS Keychain provides encrypted, access-controlled storage backed by the Secure Enclave on modern hardware. Here is how to store and retrieve a token securely:
import Security
func saveToKeychain(key: String, value: String) throws {
guard let data = value.data(using: .utf8) else {
throw KeychainError.encodingFailed
}
let query: [String: Any] = [
kSecClass as String: kSecClassGenericPassword,
kSecAttrAccount as String: key,
kSecValueData as String: data,
kSecAttrAccessible as String: kSecAttrAccessibleWhenUnlockedThisDeviceOnly
]
// Delete any existing item before adding
SecItemDelete(query as CFDictionary)
let status = SecItemAdd(query as CFDictionary, nil)
guard status == errSecSuccess else {
throw KeychainError.saveFailed(status)
}
}
func readFromKeychain(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.readFailed(status)
}
return value
}
The kSecAttrAccessibleWhenUnlockedThisDeviceOnly attribute means the item can only be accessed when the device is unlocked, and it cannot be migrated to another device through iCloud backup — a critical restriction for authentication tokens.
App Transport Security
App Transport Security (ATS) was introduced in iOS 9 and enforces that all network connections use HTTPS with strong TLS settings. By default, ATS requires:
- TLS 1.2 or higher
- Forward secrecy ciphers
- Valid certificates from a trusted CA
- No MD5 or SHA-1 in the certificate chain
Many developers disable ATS entirely by adding NSAllowsArbitraryLoads: YES to their Info.plist. This is a significant security regression. If you need to connect to a specific non-HTTPS domain (a third-party service you do not control), use targeted exceptions rather than blanket bypass:
<key>NSAppTransportSecurity</key>
<dict>
<key>NSExceptionDomains</key>
<dict>
<key>legacy-partner.example.com</key>
<dict>
<key>NSExceptionAllowsInsecureHTTPLoads</key>
<true/>
<key>NSExceptionMinimumTLSVersion</key>
<string>TLSv1.2</string>
</dict>
</dict>
</dict>
Apple's App Review process will reject apps that disable ATS entirely without a compelling justification.
Certificate Pinning
Even with valid HTTPS, a man-in-the-middle attack is possible if an attacker can install a rogue CA certificate on the device (common in enterprise MDM scenarios and during security testing). Certificate pinning prevents this by hardcoding the expected server certificate or public key into the app.
Using URLSession with a custom delegate:
class PinningDelegate: NSObject, URLSessionDelegate {
// SHA-256 hash of the server's SubjectPublicKeyInfo
private let pinnedKeyHash = "abc123...your_base64_hash_here"
func urlSession(
_ session: URLSession,
didReceive challenge: URLAuthenticationChallenge,
completionHandler: @escaping (URLSession.AuthChallengeDisposition, URLCredential?) -> Void
) {
guard challenge.protectionSpace.authenticationMethod == NSURLAuthenticationMethodServerTrust,
let serverTrust = challenge.protectionSpace.serverTrust,
let certificate = SecTrustGetCertificateAtIndex(serverTrust, 0) else {
completionHandler(.cancelAuthenticationChallenge, nil)
return
}
let serverPublicKey = SecCertificateCopyKey(certificate)
let serverPublicKeyData = SecKeyCopyExternalRepresentation(serverPublicKey!, nil)! as Data
let keyHash = sha256Hash(serverPublicKeyData)
if keyHash == pinnedKeyHash {
completionHandler(.useCredential, URLCredential(trust: serverTrust))
} else {
completionHandler(.cancelAuthenticationChallenge, nil)
}
}
}
Pin to the public key rather than the full certificate — this allows certificate rotation without shipping an app update, as long as the same key pair is used.
Biometric Authentication with LocalAuthentication
Face ID and Touch ID provide strong user authentication backed by the Secure Enclave. The LocalAuthentication framework makes integration straightforward:
import LocalAuthentication
func authenticateWithBiometrics(completion: @escaping (Bool, Error?) -> Void) {
let context = LAContext()
var error: NSError?
guard context.canEvaluatePolicy(.deviceOwnerAuthenticationWithBiometrics, error: &error) else {
completion(false, error)
return
}
context.evaluatePolicy(
.deviceOwnerAuthenticationWithBiometrics,
localizedReason: "Verify your identity to access your account"
) { success, authError in
DispatchQueue.main.async {
completion(success, authError)
}
}
}
Use .deviceOwnerAuthentication (not .deviceOwnerAuthenticationWithBiometrics) if you want to fall back to the device passcode when biometrics fail — this is usually the right choice for general authentication flows.
Combine biometrics with Keychain access control for the strongest protection:
let access = SecAccessControlCreateWithFlags(
nil,
kSecAttrAccessibleWhenPasscodeSetThisDeviceOnly,
.biometryCurrentSet,
nil
)!
The .biometryCurrentSet flag means the Keychain item becomes inaccessible if the user enrolls a new fingerprint or Face ID — preventing an attacker from adding their own biometric to gain access.
Jailbreak Detection
A jailbroken device bypasses the iOS sandboxing model, making it significantly easier to attack your app. Complete jailbreak detection is an arms race — sophisticated jailbreaks (like Dopamine or palera1n) can bypass most checks. However, layered detection raises the bar:
func isDeviceJailbroken() -> Bool {
#if targetEnvironment(simulator)
return false
#else
// Check for common jailbreak artifacts
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
}
}
// Check if app can write outside sandbox
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 {
// Expected behavior on non-jailbroken device
}
// Check for suspicious dynamic libraries
let suspiciousLibraries = ["SubstrateLoader", "CydiaSubstrate", "MobileSubstrate"]
for i in 0..<_dyld_image_count() {
if let imageName = _dyld_get_image_name(i) {
let name = String(cString: imageName)
if suspiciousLibraries.contains(where: { name.contains($0) }) {
return true
}
}
}
return false
#endif
}
Consider using a commercial solution like Guardsquare or Approov for production applications that need robust runtime protection.
Binary Protection
iOS automatically applies several binary protections, but you should verify they are enabled:
- Position Independent Executable (PIE): Enables ASLR (Address Space Layout Randomization). Check with
otool -hv YourApp | grep PIE. - Stack Canaries: Protect against stack buffer overflows. Check with
otool -I -v YourApp | grep stack_chk. - ARC (Automatic Reference Counting): Reduces memory management vulnerabilities. Ensure no
-fno-objc-arcflags in your build settings.
Enable full bitcode in your Xcode project settings to allow Apple to recompile your app with future security enhancements. Set ENABLE_BITCODE = YES in your build settings.
OWASP Mobile Top 10 for iOS
The OWASP Mobile Top 10 provides a framework for thinking about mobile security risks:
- M1 – Improper Credential Usage: Use Keychain, never UserDefaults or hardcoded credentials.
- M2 – Inadequate Supply Chain Security: Audit all third-party SDKs for permissions and network calls.
- M3 – Insecure Authentication/Authorization: Implement biometrics + server-side session validation.
- M4 – Insufficient Input/Output Validation: Validate all data from deep links, push notifications, and clipboard.
- M5 – Insecure Communication: Enable ATS, implement certificate pinning.
- M6 – Inadequate Privacy Controls: Request only necessary permissions, respect App Tracking Transparency.
- M7 – Insufficient Binary Protections: Enable PIE, stack canaries, and code obfuscation.
- M8 – Security Misconfiguration: Audit
Info.plistfor ATS exceptions and exported settings. - M9 – Insecure Data Storage: Audit all storage: Keychain, Core Data, SQLite, files, logs.
- M10 – Insufficient Cryptography: Use
CryptoKitorCommonCrypto, never roll your own crypto.
Data in Logs
A commonly overlooked issue is sensitive data appearing in system logs. Apple's Unified Logging system automatically redacts private data in release builds, but you should be explicit:
import os
let logger = Logger(subsystem: "com.yourapp", category: "auth")
// WRONG — token appears in logs
logger.debug("Token: \(token)")
// CORRECT — private data is redacted
logger.debug("Token: \(token, privacy: .private)")
iOS security is a layered discipline. No single control is sufficient — the combination of secure storage, transport security, certificate pinning, biometric authentication, and runtime protection creates the defense-in-depth that modern applications require. Regularly audit your app against the OWASP Mobile Top 10 and conduct penetration testing before major releases.