iOS App Security: Certificate Pinning, Jailbreak Detection, and Data Protection
A production app that ships with a hardcoded API key, a globally disabled ATS policy, and unprotected user files on disk is not a shipped app — it’s an open invitation. Securing an iOS app means layering multiple defenses, because no single mechanism is sufficient on its own.
This post covers the core security controls every senior iOS engineer should have in their toolkit: App Transport Security configuration, certificate pinning, data protection classes, App Attest, jailbreak detection heuristics, and secure coding practices. We won’t cover server-side security or cryptography primitives — those are separate disciplines with their own depth.
Contents
- The Problem
- App Transport Security
- Certificate Pinning
- Data Protection Classes
- App Attest
- Jailbreak Detection
- Secure Coding Practices
- Advanced Usage
- When to Use (and When Not To)
- Summary
The Problem
Consider a networking layer that has accumulated technical debt across several engineers and release cycles. The following code represents the kind of security regressions that slip into production when security is treated as an afterthought:
// PixarAPI client — accumulated security debt
final class PixarAPIClient {
// ❌ Hardcoded credential visible in binary and source control
private let apiKey = "sk_pixar_prod_a3f9b2c1d8e7"
func fetchMovieCharacters(movieID: String) async throws -> [Character] {
// ❌ Plain HTTP — data in transit is unencrypted
var request = URLRequest(url: URL(string: "http://api.pixar-internal.com/characters")!)
// ❌ No validation that this server is who it claims to be
request.setValue(apiKey, forHTTPHeaderField: "X-API-Key")
let (data, _) = try await URLSession.shared.data(for: request)
return try JSONDecoder().decode([Character].self, from: data)
}
func saveCharacterProfile(_ profile: CharacterProfile) {
// ❌ Sensitive profile data saved to Documents — accessible to iTunes backup
// and readable when device is not locked
let path = FileManager.default.urls(for: .documentDirectory, in: .userDomainMask)[0]
.appendingPathComponent("profile_\(profile.id).json")
try? JSONEncoder().encode(profile).write(to: path)
}
}
This code has four distinct vulnerabilities: a hardcoded secret, unencrypted transport, no server identity validation, and unprotected file storage. Each requires a different mitigation. Let’s work through them systematically.
App Transport Security
App Transport Security (ATS) is Apple’s policy framework that enforces HTTPS for all network connections by default. It has been required since iOS 9 and will cause App Review rejection if disabled globally without justification.
The most common mistake is disabling ATS globally to work around a legacy endpoint:
<!-- ❌ Info.plist — never do this in production -->
<key>NSAppTransportSecurity</key>
<dict>
<key>NSAllowsArbitraryLoads</key>
<true/>
</dict>
Apple may approve this configuration for specific app categories (media streaming apps, for example), but for a standard data API it will fail review and exposes all connections to downgrade attacks.
The correct approach is to use NSExceptionDomains to carve out the minimum exception necessary:
<!-- ✅ Info.plist — targeted exception for a legacy internal endpoint only -->
<key>NSAppTransportSecurity</key>
<dict>
<key>NSExceptionDomains</key>
<dict>
<key>legacy.pixar-internal.com</key>
<dict>
<!-- Allow HTTP only for this specific domain -->
<key>NSExceptionAllowsInsecureHTTPLoads</key>
<true/>
<!-- Do not cascade to subdomains -->
<key>NSIncludesSubdomains</key>
<false/>
</dict>
</dict>
</dict>
Warning:
NSAllowsArbitraryLoadsInWebContentapplies only toWKWebViewcontent loaded from user-provided URLs, not your own API calls. Do not confuse it with a blanket HTTP override.Apple Docs:
NSAppTransportSecurity— Information Property List
ATS ensures transport is encrypted, but it does not verify that the server on the other end of that encrypted connection is actually your server. That requires certificate pinning.
Certificate Pinning
TLS prevents eavesdropping, but a man-in-the-middle attack using a fraudulently issued or enterprise-installed root certificate can still intercept HTTPS traffic. Certificate pinning adds a second verification layer: your app refuses any TLS connection that does not present a specific certificate or public key you have embedded at build time.
Public key pinning is preferred over full certificate pinning because it survives certificate renewal — your pinned key remains valid as long as the private key on the server is not rotated.
Implement pinning via URLSessionDelegate
and the
urlSession(_:didReceive:completionHandler:)
challenge callback:
import CryptoKit
import Foundation
// The SHA-256 hash of the SubjectPublicKeyInfo bytes from your server's certificate.
// Generate with: openssl s_client -connect api.pixar-internal.com:443 | \
// openssl x509 -pubkey -noout | openssl pkey -pubin -outform DER | \
// openssl dgst -sha256 -binary | base64
private let pinnedPublicKeyHashes: Set<String> = [
"abc123+pixarPrimaryKeyHash/AAAABBBBCCCCddddEEEEffffGGGG=", // Primary cert
"xyz789+pixarBackupKeyHash/ZZZZYYYYXXXXwwwwVVVVuuuuTTTT=", // Backup/rotation cert
]
final class PinningSessionDelegate: NSObject, URLSessionDelegate {
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 = SecTrustCopyCertificateChain(serverTrust) as? [SecCertificate],
let leafCertificate = certificate.first
else {
completionHandler(.cancelAuthenticationChallenge, nil)
return
}
// Extract the SubjectPublicKeyInfo (SPKI) from the leaf certificate
guard let publicKey = SecCertificateCopyKey(leafCertificate),
let publicKeyData = SecKeyCopyExternalRepresentation(publicKey, nil) as Data?
else {
completionHandler(.cancelAuthenticationChallenge, nil)
return
}
// Hash the raw key bytes with SHA-256 and base64-encode
let hash = SHA256.hash(data: publicKeyData)
let hashBase64 = Data(hash).base64EncodedString()
if pinnedPublicKeyHashes.contains(hashBase64) {
completionHandler(.useCredential, URLCredential(trust: serverTrust))
} else {
// Key does not match — reject the connection
completionHandler(.cancelAuthenticationChallenge, nil)
}
}
}
// Wire the delegate into the URLSession used by your API client
let pinningSession = URLSession(
configuration: .default,
delegate: PinningSessionDelegate(),
delegateQueue: nil
)
A few production considerations worth highlighting:
- Always pin two keys — the active key and a backup. If you pin only one and the server key is rotated (due to expiry or compromise), your app stops working for all users until an update ships.
- Provide an emergency override mechanism — some teams use a server-delivered “unpin” flag signed with a separate key to handle catastrophic rotation scenarios between releases.
- Exclude development builds — pinning during local development against a self-signed cert will block all requests.
Use
#if DEBUGguards or a build flag to skip the delegate in development.
Warning: Certificate pinning is not a substitute for proper TLS configuration. Pinning a weak certificate or a certificate with a short chain still exposes you to other TLS vulnerabilities.
Data Protection Classes
Even with encrypted transport, data persisted to disk can be read by an attacker with physical device access if the file is not protected by iOS’s Data Protection framework.
Data Protection is backed by per-file encryption keys that are derived from the user’s passcode and the device’s hardware UID. The four protection classes control when the decryption key is available:
| Class | Constant | Key Available |
|---|---|---|
| Complete | .complete | Only while device is unlocked |
| Complete Unless Open | .completeUnlessOpen | While unlocked, and while any file handle remains open |
| Complete Until First Authentication | .completeUntilFirstUserAuthentication | After first unlock since boot |
| None | .none | Always (including when locked) |
For files containing sensitive user data, use .complete:
import Foundation
func saveCharacterProfile(_ profile: CharacterProfile, id: String) throws {
let data = try JSONEncoder().encode(profile)
let directory = FileManager.default.urls(for: .applicationSupportDirectory, in: .userDomainMask)[0]
let fileURL = directory.appendingPathComponent("profile_\(id).json")
// ✅ Write with CompleteFileProtection — encrypted when device is locked
try data.write(to: fileURL, options: .completeFileProtection)
}
For Keychain items, the equivalent is the kSecAttrAccessible attribute. The appropriate value for credentials that
should only be accessible while the device is unlocked is
kSecAttrAccessibleWhenUnlockedThisDeviceOnly:
import Security
func storePixarToken(_ token: String, account: String) throws {
let query: [String: Any] = [
kSecClass as String: kSecClassGenericPassword,
kSecAttrService as String: "com.pixar.app.api",
kSecAttrAccount as String: account,
kSecValueData as String: Data(token.utf8),
// ✅ Accessible only when device is unlocked, non-transferable between devices
kSecAttrAccessible as String: kSecAttrAccessibleWhenUnlockedThisDeviceOnly,
]
let status = SecItemAdd(query as CFDictionary, nil)
guard status == errSecSuccess else {
throw KeychainError.unexpectedStatus(status)
}
}
Note:
ThisDeviceOnlyattributes are not transferred during iCloud Keychain sync or device backup. For credentials that should roam across a user’s devices, usekSecAttrAccessibleWhenUnlockedwithout theThisDeviceOnlysuffix — but accept the trade-off that the item appears in backups.
App Attest
ATS and pinning protect the network channel, and data protection protects files at rest. But none of these controls verify that the code running on the device is your unmodified app binary. An attacker who hooks your app with a dynamic library can bypass all of the above.
DeviceCheck App Attest (introduced in iOS 14) lets your server cryptographically verify two things: that the request originated from a genuine Apple device, and that the app binary on that device is your unmodified App Store build.
import CryptoKit
import DeviceCheck
@available(iOS 14.0, *)
final class PixarAppAttestService {
private let attestService = DCAppAttestService.shared
// Step 1: Generate an attested key pair and store the keyId
func generateAttestedKey() async throws -> String {
guard attestService.isSupported else {
throw AttestError.notSupported
}
let keyId = try await attestService.generateKey()
// In production: send keyId to your server. Your server will send back
// a challenge nonce for the attestation step.
return keyId
}
// Step 2: Attest the key against Apple's servers using a server-provided challenge
func attestKey(keyId: String, serverChallenge: Data) async throws -> Data {
// Hash the server challenge — App Attest requires a SHA-256 digest
let clientDataHash = Data(SHA256.hash(data: serverChallenge))
let attestation = try await attestService.attestKey(keyId, clientDataHash: clientDataHash)
// Send `attestation` to your server, which validates it against Apple's
// App Attest API and stores the public key for future assertion verification
return attestation
}
// Step 3: Generate per-request assertions to prove ongoing integrity
func generateAssertion(keyId: String, requestData: Data) async throws -> Data {
let clientDataHash = Data(SHA256.hash(data: requestData))
return try await attestService.generateAssertion(keyId, clientDataHash: clientDataHash)
}
}
The attestation flow works as follows: your app attests a key pair with Apple’s servers, which cryptographically bind
that key to your app’s App ID and Team ID. Your server then validates the attestation receipt. Subsequent requests use
generateAssertion to sign request data with the attested key, and your server verifies each assertion — rejecting any
request that cannot produce a valid signature from the attested key.
Note: App Attest does not work in Simulator or in development builds signed with a development certificate. Gate all App Attest code behind
DCAppAttestService.shared.isSupportedand provide a fallback path for unsupported environments.Apple Docs:
DCAppAttestService— DeviceCheck
Jailbreak Detection
A jailbroken device has a modified kernel that removes the sandbox restrictions iOS relies on. Apps running on a jailbroken device can be inspected, modified, and have their method calls intercepted at runtime. Detection is a useful signal for risk scoring, but it cannot be made foolproof — a sufficiently motivated attacker with a jailbroken device can hook your detection logic itself.
Treat jailbreak detection as one layer of a defense-in-depth strategy, not as a hard security boundary:
import Foundation
import UIKit
struct JailbreakDetector {
static var isDeviceJailbroken: Bool {
#if targetEnvironment(simulator)
return false
#else
return checkSuspiciousFiles()
|| checkSandboxEscape()
|| checkDynamicLibraries()
#endif
}
// Common jailbreak toolchain artifacts
private static func checkSuspiciousFiles() -> Bool {
let suspiciousPaths = [
"/Applications/Cydia.app",
"/Library/MobileSubstrate/MobileSubstrate.dylib",
"/bin/bash",
"/usr/sbin/sshd",
"/etc/apt",
"/private/var/lib/apt",
]
return suspiciousPaths.contains { FileManager.default.fileExists(atPath: $0) }
}
// On a genuine device, writing outside the sandbox raises an error
private static func checkSandboxEscape() -> Bool {
let testPath = "/private/pixar_jailbreak_probe_\(UUID().uuidString)"
do {
try "probe".write(toFile: testPath, atomically: true, encoding: .utf8)
try FileManager.default.removeItem(atPath: testPath)
return true // Write succeeded — sandbox is compromised
} catch {
return false
}
}
// Look for injected Substrate/Frida libraries in the process image list
private static func checkDynamicLibraries() -> Bool {
let suspiciousLibraries = ["MobileSubstrate", "FridaGadget", "frida"]
for i in 0..<_dyld_image_count() {
guard let imageName = _dyld_get_image_name(i) else { continue }
let name = String(cString: imageName)
if suspiciousLibraries.contains(where: { name.contains($0) }) {
return true
}
}
return false
}
}
Warning: All of these checks can be bypassed on a jailbroken device by hooking
FileManager,_dyld_get_image_name, or the write syscall. Never gate critical business logic solely on jailbreak detection. Use it for risk scoring and to make exploitation harder — not to guarantee security.
The appropriate response to a detected jailbreak depends on your app’s risk profile. A banking app may refuse to operate. A streaming app may disable offline downloads. A general consumer app may log the event for fraud analysis without affecting the user experience.
Secure Coding Practices
Beyond the network and storage layers, several coding-level practices significantly reduce your attack surface.
No Hardcoded Secrets
API keys, client secrets, and encryption keys must never appear in source code or compiled into the binary as string
literals. Attackers routinely extract these using strings on the binary or tools like Hopper. Instead:
- Deliver secrets at runtime from your server, authenticated via App Attest assertions.
- For keys that must be embedded (e.g., a public key for local verification), store them in a Keychain item provisioned at first launch, not as a Swift string constant.
- Use environment-specific build configurations to prevent production keys from appearing in development builds.
Input Validation
Never trust data from outside your process boundary — network responses, URL scheme parameters, push notification payloads, and clipboard contents are all attack surfaces:
struct PixarMovieIDValidator {
// Movie IDs are UUIDs — reject anything that does not conform
static func validate(_ input: String) throws -> String {
let uuidPattern = /^[0-9A-Fa-f]{8}-[0-9A-Fa-f]{4}-[0-9A-Fa-f]{4}-[0-9A-Fa-f]{4}-[0-9A-Fa-f]{12}$/
guard input.wholeMatch(of: uuidPattern) != nil else {
throw ValidationError.invalidMovieID(input)
}
return input
}
}
Using Swift’s Regex with literal patterns (introduced in
Swift 5.7, compile-time validated in Swift 6) is preferred over NSRegularExpression string patterns, which are
runtime-validated and error-prone.
Privacy Manifest
Since Xcode 15, Apple requires a
PrivacyInfo.xcprivacy manifest for
any app or third-party SDK that accesses privacy-sensitive APIs. The manifest declares which APIs you use, the reason
for each use, and whether any data is collected:
<!-- PrivacyInfo.xcprivacy -->
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN"
"http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>NSPrivacyAccessedAPITypes</key>
<array>
<dict>
<key>NSPrivacyAccessedAPIType</key>
<string>NSPrivacyAccessedAPICategoryUserDefaults</string>
<key>NSPrivacyAccessedAPITypeReasons</key>
<array>
<!-- CA92.1: Accessing info the user provided to the app -->
<string>CA92.1</string>
</array>
</dict>
</array>
<key>NSPrivacyCollectedDataTypes</key>
<array/>
<key>NSPrivacyTracking</key>
<false/>
</dict>
</plist>
Apps that access required reason APIs without a valid PrivacyInfo.xcprivacy receive App Store rejection. Audit your
dependencies as well — third-party SDKs must also declare their privacy manifests, and missing manifests in your
dependency tree fail review.
Advanced Usage
Biometric Authentication with LocalAuthentication
The Data Protection and Keychain layers protect data at rest, but you may want to require active biometric authentication to access particularly sensitive operations — authorizing a payment, revealing a token, or confirming a destructive action.
LAContext provides the local biometric
evaluation:
import LocalAuthentication
final class PixarBiometricGate {
func requireBiometricAuth(reason: String) async throws {
let context = LAContext()
var error: NSError?
guard context.canEvaluatePolicy(.deviceOwnerAuthenticationWithBiometrics, error: &error) else {
throw BiometricError.unavailable(error)
}
// Throws LAError on cancellation or failure
try await context.evaluatePolicy(
.deviceOwnerAuthenticationWithBiometrics,
localizedReason: reason
)
}
}
// Usage: require Face ID before revealing a stored token
func revealPixarAPIToken() async throws -> String {
let gate = PixarBiometricGate()
try await gate.requireBiometricAuth(reason: "Confirm your identity to access your API token")
return try KeychainManager.shared.retrieveToken(account: "pixar.api")
}
Secure Enclave Key Storage
For the highest-security key storage — private keys used for signing, encryption keys for locally sensitive data — use the Secure Enclave. Keys generated in the Secure Enclave never leave the hardware security module; cryptographic operations happen inside the enclave:
import Security
import CryptoKit
func generateSecureEnclaveKey(tag: String) throws -> SecureEnclave.P256.Signing.PrivateKey {
// Requires biometric or passcode authentication to use the key
let accessControl = SecAccessControlCreateWithFlags(
kCFAllocatorDefault,
kSecAttrAccessibleWhenUnlockedThisDeviceOnly,
[.privateKeyUsage, .biometryCurrentSet],
nil
)!
return try SecureEnclave.P256.Signing.PrivateKey(
accessControl: accessControl
)
}
Note: Secure Enclave keys are available on iPhone 5s and later and all Apple Silicon Macs. They cannot be exported, backed up, or migrated — losing the device means losing the key.
When to Use (and When Not To)
| Scenario | Recommendation |
|---|---|
| Internal enterprise app with controlled device fleet | Certificate pinning is high value; App Attest may be overkill |
| Consumer app handling payments or health data | Full stack: ATS + pinning + Data Protection .complete + App Attest |
| App using third-party CDNs or content delivery | Pin your API endpoints only; exclude CDN domains from pinning |
| App with rapid certificate rotation (< 30-day certs) | Pin public key, not certificate; maintain a backup pin |
| Low-risk utility app (calculator, unit converter) | ATS defaults + Data Protection .completeUntilFirstUserAuthentication is sufficient |
| App integrating third-party SDKs with unknown security posture | Audit SDK PrivacyInfo.xcprivacy; sandbox SDK network calls if possible |
| App that needs to detect modified binaries | App Attest is the only reliable approach; jailbreak detection alone is insufficient |
| Development / CI environment | Disable pinning with #if DEBUG; use mocked responses for security-sensitive flows |
Summary
- App Transport Security should be configured with targeted
NSExceptionDomainsexceptions, never disabled globally withNSAllowsArbitraryLoads. - Certificate pinning via
URLSessionDelegateadds a second layer of server identity verification beyond TLS; always pin two keys (active and backup) to survive rotation. - Data Protection classes and
kSecAttrAccessibleWhenUnlockedThisDeviceOnlyensure that sensitive files and Keychain items are encrypted when the device is locked. - App Attest provides cryptographic proof that API requests originate from your unmodified App Store binary on a genuine Apple device — use it for high-risk operations.
- Jailbreak detection is a useful risk signal but cannot be made foolproof; treat it as one layer of a defense-in-depth strategy, not a hard security boundary.
Security is cumulative — each layer you add increases the cost of exploitation. No single control is sufficient, but together they make your app a significantly harder target than the majority of what’s on the App Store.