iOS Application Security: SSL Pinning, Keychain, API Encryption and App Attest
iOS has a strong security baseline: code signing, sandboxing, App Transport Security, Data Protection and the Secure Enclave are all on by default. That baseline leads many teams to assume the platform covers everything. It doesn't. The platform protects the user from malicious apps very well, but it does much less to protect your backend from a modified copy of your app, a scripted client that impersonates it, or a user who deliberately intercepts their own traffic. This guide looks at four questions that come up in almost every security review of an iOS app. For each one, the goal is an honest answer: what the technique actually protects against, what it does not, and how to implement it correctly if you decide you need it. Is SSL Pinning Still Necessary? Secure Keychain Storage vs UserDefaults API Request Encryption Beyond HTTPS Device Attestation, App Attest and Application Integrity Code samples use Swift 5.9+ with CryptoKit, Security and DeviceCheck. Minimum OS versions are noted where an API requires them. A threat model to keep in mind Attacker Capabilities Typical goal Network attacker Public Wi-Fi, rogue access point, compromised router or CA Read or modify traffic of an unsuspecting user Curious / hostile user Owns the device, installs a proxy CA, may jailbreak, runs Frida Understand your API, bypass paywalls, cheat, scrape Automated abuser Scripts calling your API directly, emulators, device farms Fake signups, credential stuffing, promo abuse, bots Device thief / forensics Physical access, backups, possibly a jailbreak exploit Extract tokens, credentials, personal data at rest Keep these four actors in mind as you read. Most disagreements about whether a control is "necessary" come from people silently assuming different attackers. 1. Is SSL Pinning Still Necessary? TL;DR — For most apps, ATS plus Certificate Transparency is enough, and pinning adds outage risk for limited gain. Pinning is still worth it for banking, health, payments, enterprise and high-value APIs, where a mis-issued or locally installed certificate is a realistic threat. If you pin, pin public keys (SPKI), pin at the CA or intermediate level, always ship a backup pin, and prefer Apple's declarative NSPinnedDomains over hand-written code. What pinning actually does During a normal TLS handshake, iOS checks that the server certificate chains up to any root in the device trust store and that the hostname matches. Pinning adds a second, stricter rule: the chain must also contain a specific certificate or public key that you chose. Even a perfectly valid certificate from another CA is rejected. That closes two gaps that standard validation leaves open: Mis-issued or compromised CA certificates. Any of the ~150 trusted roots can, in principle, issue a certificate for your domain. User- or MDM-installed root certificates. Corporate proxies, debugging tools (Charles, Proxyman, mitmproxy) and malware-installed profiles all rely on adding a root that iOS then trusts for every app. What has changed since pinning became popular Pinning became standard advice around 2012-2015. Several things have shifted since: App Transport Security (iOS 9+) enforces TLS 1.2+, forward secrecy and strong ciphers by default. Plain HTTP requires an explicit exception. Certificate Transparency is enforced by Apple for publicly trusted certificates. A mis-issued certificate must be publicly logged, which makes silent attacks much harder and lets you monitor logs for your domain. Custom root certificates require explicit user action. Since iOS 10.3 a user must manually enable full trust for an installed profile's root in Settings. Certificate lifetimes are shrinking. Public TLS certificates now rotate far more often than they used to, and the CA/Browser Forum has approved a schedule that keeps shortening maximum validity over the next few years. Leaf pinning is becoming an operational hazard. Apple added declarative pinning. Since iOS 14, NSPinnedDomains in Info.plist lets you pin without writing trust-evaluation code. What pinning does NOT protect against Be precise about this in design reviews, because pinning is often oversold: A user reverse-engineering your API on their own jailbroken device. Tools like Frida and objection disable pinning in seconds by hooking the trust-evaluation functions. Pinning raises the bar for casual inspection, nothing more. Server-side vulnerabilities. Broken authorization, injection and IDOR bugs are reachable through any client, including curl. Compromised devices. Malware with root access reads data before it is encrypted. In other words, pinning protects the honest user on a hostile network. It is not a defense against the device owner. The real cost: bricking your own app The classic pinning failure is self-inflicted. You pin the leaf certificate, the certificate auto-renews with a new key, and every installed copy of the app stops talking to your backend until users update, which some never do. Teams that have experienced this outage usually remove pinning entirely rather than fix it, which is the wrong lesson. Decision guide App type Recommendation Content, social, utilities, most consumer apps Do not pin. Rely on ATS + Certificate Transparency. Invest in server-side auth and App Attest. Fintech, banking, payments, crypto wallets Pin SPKI of your CA / intermediate with at least one backup. Often required by regulators and pentest checklists (OWASP MASVS-NETWORK). Healthcare, government, enterprise / MDM apps Pin, unless your customers' corporate TLS inspection is a requirement; then document the exception. Apps talking to your own private PKI Pin your private root; standard validation won't trust it anyway. Option A: Declarative pinning with NSPinnedDomains (recommended) This is Apple's own mechanism, evaluated by the networking stack for URLSession, WKWebView and Network.framework. No delegate code, no chance of forgetting to call the completion handler. NSAppTransportSecurity NSPinnedDomains api.example.com NSIncludesSubdomains NSPinnedCAIdentities SPKI-SHA256-BASE64 PRIMARY_CA_SPKI_HASH_BASE64= SPKI-SHA256-BASE64 BACKUP_CA_SPKI_HASH_BASE64= Use NSPinnedLeafIdentities instead only if you control certificate issuance and key reuse tightly. Generate a pin from a live server like this: # Compute an SPKI SHA-256 pin openssl s_client -connect api.example.com:443 -servername api.example.com -showcerts Void) { guard challenge.protectionSpace.authenticationMethod == NSURLAuthenticationMethodServerTrust, let trust = challenge.protectionSpace.serverTrust else { completionHandler(.performDefaultHandling, nil); return } // 1. ALWAYS run the system evaluation first (chain, hostname, expiry, CT). var error: CFError? guard SecTrustEvaluateWithError(trust, &error) else { completionHandler(.cancelAuthenticationChallenge, nil); return } // 2. Then require that some certificate in the chain matches a pin. let chain = (SecTrustCopyCertificateChain(trust) as? [SecCertificate]) ?? [] let matched = chain.contains { cert in guard let hash = Self.spkiHash(of: cert) else { return false } return pinnedHashes.contains(hash) } if matched { completionHandler(.useCredential, URLCredential(trust: trust)) } else { // Report to telemetry here, then fail closed. completionHandler(.cancelAuthenticationChallenge, nil) } } private static func spkiHash(of cert: SecCertificate) -> String? { guard let key = SecCertificateCopyKey(cert), let raw = SecKeyCopyExternalRepresentation(key, nil) as Data?, let attrs = SecKeyCopyAttributes(key) as? [CFString: Any], let type = attrs[kSecAttrKeyType] as? String, let bits = attrs[kSecAttrKeySizeInBits] as? Int else { return nil } let header: [UInt8] switch (type, bits) { case (kSecAttrKeyTypeRSA as String, 2048): header = rsa2048Header case (kSecAttrKeyTypeECSECPrimeRandom as String, 256): header = ecP256Header default: return nil // add headers for RSA-4096 / P-384 if your chain uses them } let digest = SHA256.hash(data: Data(header) + raw) return Data(digest).base64EncodedString() } } Operational checklist Pin public keys (SPKI), never whole certificates. Keys can survive renewal; certificates can't. Pin the intermediate or root CA unless you have a strong reason to pin the leaf. Ship at least two pins, one of which is a backup key or second CA not currently in use. Add a remote kill switch or pin-update channel (signed config) so an incident does not require an App Store release. Log pin failures to analytics. A spike tells you about either an attack or your own misconfiguration. Keep pins out of debug builds or make them configurable, so QA can still use proxies. Monitor Certificate Transparency logs for your domains (crt.sh, or a CT monitoring service). 2. Secure Keychain Storage vs UserDefaults TL;DR — UserDefaults is a preferences file, not secure storage. Anything that grants access (tokens, passwords, API keys, encryption keys, PII) belongs in the Keychain with the strictest accessibility class your app can tolerate, ideally a ThisDeviceOnly variant. For the highest-value secrets, generate keys inside the Secure Enclave so they can never be exported at all. Where UserDefaults data actually lives UserDefaults.standard writes a property list to Library/Preferences/.plist inside your app's container. That file: is stored as plain text / binary plist, with no application-level encryption; is protected only by the default file Data Protection class, which means it is readable whenever the device has been unlocked once since boot; is included in iCloud and computer backups and can be read from an unencrypted local backup with free tools; is trivially readable on a jailbroken device, and by anyone with filesystem access to a simulator or test device. UserDefaults is perfect for what it was designed for: theme choice, onboarding flags, last selected tab. It was never meant to hold secrets. How the Keychain is different Keychain items are stored in an encrypted SQLite database managed by the securityd daemon, not inside your container. Each item is encrypted with a per-item key, wrapped by a class key that is tied to the device UID (fused into the Secure Enclave hardware) and, for most classes, to the user's passcode. Your app never touches the database directly; it asks securityd through the SecItem API, which enforces your app's access group. Side-by-side comparison UserDefaults Keychain Encryption at rest File-level Data Protection only Per-item encryption, hardware-bound keys Access control Anyone with access to the container securityd enforces app / access group; optional biometrics Backups Always included Included unless ThisDeviceOnly; encrypted Survives app deletion No Usually yes (plan for it) Sharing between apps App Groups suite Keychain access groups Performance Fast, cached in memory Slower IPC call; cache in memory carefully Good for Preferences, flags, non-sensitive state Tokens, passwords, keys, secrets, PII Choosing an accessibility class The kSecAttrAccessible attribute decides when an item can be decrypted and whether it can leave the device in a backup. Pick the strictest one that still works for your feature: Class Readable when Migrates to new device? Use for WhenPasscodeSetThisDeviceOnly Unlocked, and only if a passcode is set (item is deleted if passcode is removed) No Highest-value secrets WhenUnlockedThisDeviceOnly Device unlocked No Session tokens used in foreground WhenUnlocked (default) Device unlocked Yes Credentials you want restored AfterFirstUnlockThisDeviceOnly After first unlock since boot No Tokens needed by background refresh / push AfterFirstUnlock After first unlock since boot Yes Background access + restore All class names are prefixed with kSecAttrAccessible. The old Always classes are deprecated; do not use them. A small, correct Keychain wrapper Most Keychain bugs come from three mistakes: not handling errSecDuplicateItem, querying with a different set of attributes than you saved with, and ignoring errSecInteractionNotAllowed when code runs in the background while the device is locked. This wrapper handles all three. // KeychainStore.swift import Foundation import Security enum KeychainError: Error { case unexpectedStatus(OSStatus) case itemNotFound case locked // errSecInteractionNotAllowed: device locked, try later } struct KeychainStore { let service: String var accessGroup: String? = nil var accessibility: CFString = kSecAttrAccessibleWhenUnlockedThisDeviceOnly private func baseQuery(_ account: String) -> [CFString: Any] { var q: [CFString: Any] = [ kSecClass: kSecClassGenericPassword, kSecAttrService: service, kSecAttrAccount: account, kSecUseDataProtectionKeychain: true // consistent behaviour on macOS too ] if let accessGroup { q[kSecAttrAccessGroup] = accessGroup } return q } func set(_ data: Data, for account: String) throws { let query = baseQuery(account) let update: [CFString: Any] = [kSecValueData: data, kSecAttrAccessible: accessibility] var status = SecItemUpdate(query as CFDictionary, update as CFDictionary) if status == errSecItemNotFound { var add = query add.merge(update) { $1 } status = SecItemAdd(add as CFDictionary, nil) } try check(status) } func data(for account: String) throws -> Data { var query = baseQuery(account) query[kSecReturnData] = true query[kSecMatchLimit] = kSecMatchLimitOne var result: AnyObject? let status = SecItemCopyMatching(query as CFDictionary, &result) try check(status) guard let data = result as? Data else { throw KeychainError.itemNotFound } return data } func delete(_ account: String) throws { let status = SecItemDelete(baseQuery(account) as CFDictionary) if status != errSecItemNotFound { try check(status) } } private func check(_ status: OSStatus) throws { switch status { case errSecSuccess: return case errSecItemNotFound: throw KeychainError.itemNotFound case errSecInteractionNotAllowed: throw KeychainError.locked default: throw KeychainError.unexpectedStatus(status) } } } // Usage let tokens = KeychainStore(service: "com.example.app.auth") try tokens.set(Data(accessToken.utf8), for: "accessToken") Protecting an item with Face ID / Touch ID You can bind an item to biometrics so it can only be read after the user authenticates. The check happens inside the Secure Enclave, not in your app process, so hooking LAContext does not bypass it. // Biometric-bound Keychain item import LocalAuthentication func storeBiometricProtected(_ secret: Data, account: String) throws { var cfError: Unmanaged? guard let access = SecAccessControlCreateWithFlags( nil, kSecAttrAccessibleWhenPasscodeSetThisDeviceOnly, .biometryCurrentSet, // invalidated if fingerprints / face are re-enrolled &cfError) else { throw cfError!.takeRetainedValue() as Error } let query: [CFString: Any] = [ kSecClass: kSecClassGenericPassword, kSecAttrService: "com.example.app.vault", kSecAttrAccount: account, kSecAttrAccessControl: access, kSecValueData: secret, kSecUseDataProtectionKeychain: true ] SecItemDelete([kSecClass: kSecClassGenericPassword, kSecAttrService: "com.example.app.vault", kSecAttrAccount: account, kSecUseDataProtectionKeychain: true] as CFDictionary) let status = SecItemAdd(query as CFDictionary, nil) guard status == errSecSuccess else { throw KeychainError.unexpectedStatus(status) } } func readBiometricProtected(account: String) throws -> Data { let context = LAContext() context.localizedReason = "Unlock your vault" let query: [CFString: Any] = [ kSecClass: kSecClassGenericPassword, kSecAttrService: "com.example.app.vault", kSecAttrAccount: account, kSecReturnData: true, kSecUseAuthenticationContext: context, kSecUseDataProtectionKeychain: true ] var result: AnyObject? let status = SecItemCopyMatching(query as CFDictionary, &result) // shows Face ID guard status == errSecSuccess, let data = result as? Data else { throw KeychainError.unexpectedStatus(status) } return data } Remember to add NSFaceIDUsageDescription to Info.plist. Use .userPresence instead of .biometryCurrentSet if you want passcode fallback. Going further: keys that never leave the Secure Enclave For signing keys, the best secret is one your app can use but never read. CryptoKit makes this a few lines. The private key is generated inside the Secure Enclave; your app only receives an encrypted, device-bound blob that is useless on any other device. // Secure Enclave signing key import CryptoKit guard SecureEnclave.isAvailable else { /* fall back to a software P256 key */ } let key = try SecureEnclave.P256.Signing.PrivateKey() // Persist the opaque handle (NOT the key material) in the Keychain: try tokens.set(key.dataRepresentation, for: "deviceSigningKey") // Later let blob = try tokens.data(for: "deviceSigningKey") let restored = try SecureEnclave.P256.Signing.PrivateKey(dataRepresentation: blob) let signature = try restored.signature(for: Data("payload".utf8)) let publicKeyToRegister = restored.publicKey.derRepresentation Common mistakes Keychain items survive uninstall. If a user deletes and reinstalls, an old token may still be there. Store a "has launched" flag in UserDefaults (which is deleted) and wipe stale Keychain items on first launch. Hard-coding API secrets in the binary and then "protecting" them in the Keychain. Anything compiled into the app can be extracted with strings. Real secrets must stay on the server. Reading Keychain items from background tasks with a WhenUnlocked class, then treating errSecInteractionNotAllowed as "logged out". Logging tokens with print or os_log using %{public}@. Forgetting other leak paths: URLCache, screenshots in the app switcher, pasteboard, Core Data / SwiftData stores, and crash reports can all contain sensitive data. 3. API Request Encryption Beyond HTTPS TL;DR — HTTPS already encrypts your traffic in transit, so adding a second layer is not automatically better. It earns its place in specific situations: TLS terminates before your trusted service, you need end-to-end confidentiality for regulated data, you must prove a request was not tampered with or replayed, or you want requests bound to a specific device. The strongest pattern is device-bound request signing (Secure Enclave key) plus, where needed, payload encryption with ephemeral key agreement (ECDH or HPKE). A shared symmetric key baked into the app is not security. What HTTPS gives you, and where it stops TLS gives confidentiality, integrity and server authentication between two endpoints. The important word is endpoints. In real architectures, TLS often ends well before the code that handles the data: CDNs, WAFs and load balancers terminate TLS and see plaintext request bodies; API gateways and service meshes may log headers and payloads for debugging; on the client, a user with a proxy CA (or a hooked TLS library) reads every request; TLS says nothing about which client sent a request, or whether it is a replay. Application-layer protection addresses those gaps. It does not stop a determined attacker who controls the device from reading data before it is encrypted, and it's worth being upfront about that. Three distinct goals, three different tools Goal Tool Protects against Integrity + authenticity Request signing (asymmetric, device-bound key) Tampering by intermediaries, forged requests from other clients, stolen bearer tokens being used elsewhere Freshness Timestamp + nonce inside the signed data Replay of captured requests Confidentiality end-to-end Payload encryption (ECDH / HPKE + AES-GCM) Plaintext exposure at TLS terminators, logs, proxies Anti-pattern: a static AES key in the app The most common "encryption beyond HTTPS" implementation is AES with a key compiled into the app. Every copy of the app shares the same key, it can be extracted from the binary in minutes, and once it leaks every user's traffic is exposed retroactively. HMAC with an embedded secret has the same flaw for signing. If the key ships in the IPA, assume the attacker has it. Pattern 1: Device-bound request signing At registration (ideally right after App Attest, see section 4), the app creates a Secure Enclave key and sends the public key to the server, which associates it with the user and device. Every sensitive request is then signed. A stolen access token is useless without the private key, which cannot leave the device. Build a canonical string Signature schemes break when client and server serialize differently. Define a strict canonical form and hash the body rather than including it raw: METHOD \n PATH_WITH_QUERY \n TIMESTAMP (unix seconds) \n NONCE (random, base64url) \n BASE64(SHA256(BODY)) // RequestSigner.swift import CryptoKit import Foundation struct RequestSigner { let key: SecureEnclave.P256.Signing.PrivateKey let keyID: String // id the server assigned at registration func sign(_ request: inout URLRequest) throws { let method = request.httpMethod ?? "GET" let path = request.url.map { u in u.path + (u.query.map { "?" + $0 } ?? "") } ?? "/" let timestamp = String(Int(Date().timeIntervalSince1970)) let nonce = Self.randomNonce() let bodyHash = Data(SHA256.hash(data: request.httpBody ?? Data())) .base64EncodedString() let canonical = [method, path, timestamp, nonce, bodyHash] .joined(separator: "\n") let signature = try key.signature(for: Data(canonical.utf8)) request.setValue(keyID, forHTTPHeaderField: "X-Key-Id") request.setValue(timestamp, forHTTPHeaderField: "X-Timestamp") request.setValue(nonce, forHTTPHeaderField: "X-Nonce") request.setValue(signature.derRepresentation.base64EncodedString(), forHTTPHeaderField: "X-Signature") } private static func randomNonce() -> String { var bytes = [UInt8](repeating: 0, count: 16) _ = SecRandomCopyBytes(kSecRandomDefault, bytes.count, &bytes) return Data(bytes).base64EncodedString() .replacingOccurrences(of: "+", with: "-") .replacingOccurrences(of: "/", with: "_") .replacingOccurrences(of: "=", with: "") } } Server-side verification rules Reject if |now - timestamp| exceeds a small window (e.g. 60-300 seconds). Reject if the nonce has been seen within that window (store nonces in Redis with a TTL). Rebuild the canonical string from the received request, never from client-sent values alone. Verify the ECDSA P-256 signature with the public key registered for X-Key-Id, and check that key belongs to the authenticated user. Pattern 2: End-to-end payload encryption When the payload itself must stay confidential past TLS termination, encrypt it to a public key whose private half lives only in the service that needs the data (ideally in an HSM or KMS). The client generates a fresh ephemeral key for every request, so there is no shared secret in the app and past traffic stays safe even if a device is compromised later. Option 2a: HPKE (iOS 17+) Hybrid Public Key Encryption (RFC 9180) is the standardized version of exactly this pattern, and CryptoKit supports it natively. Prefer it when you can raise your deployment target, because the server side can use any RFC 9180 library. // HPKE with CryptoKit import CryptoKit @available(iOS 17.0, *) func sealWithHPKE(_ plaintext: Data, serverPublicKey: P256.KeyAgreement.PublicKey) throws -> (encapsulatedKey: Data, ciphertext: Data) { let suite = HPKE.Ciphersuite.P256_SHA256_AES_GCM_256 let info = Data("com.example.api.v1".utf8) // binds ciphertext to this protocol var sender = try HPKE.Sender(recipientKey: serverPublicKey, ciphersuite: suite, info: info) let ciphertext = try sender.seal(plaintext) return (sender.encapsulatedKey, ciphertext) } Option 2b: ECDH + HKDF + AES-GCM (iOS 13+) If you support older OS versions, the same construction can be built from CryptoKit primitives. Document the exact parameters so the backend team can mirror them. // Manual hybrid encryption import CryptoKit struct EncryptedEnvelope: Codable { let ephemeralPublicKey: String // base64 x9.63 let salt: String // base64 let ciphertext: String // base64 AES.GCM combined (nonce|ct|tag) } func encrypt(_ plaintext: Data, to serverKey: P256.KeyAgreement.PublicKey, associatedData: Data) throws -> (EncryptedEnvelope, SymmetricKey) { let ephemeral = P256.KeyAgreement.PrivateKey() // new for every request let shared = try ephemeral.sharedSecretFromKeyAgreement(with: serverKey) var saltBytes = [UInt8](repeating: 0, count: 32) _ = SecRandomCopyBytes(kSecRandomDefault, saltBytes.count, &saltBytes) let salt = Data(saltBytes) let key = shared.hkdfDerivedSymmetricKey( using: SHA256.self, salt: salt, sharedInfo: Data("com.example.api.v1 request".utf8), outputByteCount: 32) // Authenticate method + path so a payload can't be moved to another endpoint let sealed = try AES.GCM.seal(plaintext, using: key, authenticating: associatedData) let envelope = EncryptedEnvelope( ephemeralPublicKey: ephemeral.publicKey.x963Representation.base64EncodedString(), salt: salt.base64EncodedString(), ciphertext: sealed.combined!.base64EncodedString()) return (envelope, key) // keep `key` to decrypt the server's response } func decryptResponse(_ combined: Data, key: SymmetricKey, associatedData: Data) throws -> Data { let box = try AES.GCM.SealedBox(combined: combined) return try AES.GCM.open(box, using: key, authenticating: associatedData) } For responses, a cleaner design derives a separate response key from the same shared secret with a different sharedInfo label, so request and response never reuse a key/nonce space. Never reuse a GCM nonce with the same key. Key management on the server Keep the decryption private key in a KMS or HSM; the app only ships the public key. Include a key version (kid) in every envelope so you can rotate without breaking old app versions. Pin or sign the public key distribution: if you fetch it at runtime, verify a signature over it with a key built into the app. Never log decrypted payloads; log request IDs instead. When NOT to do this Payload encryption costs real things: harder debugging, no WAF inspection of bodies, more complex client and server code, and crypto that someone must maintain. If your only goal is "stop people seeing our JSON in Charles", it will not achieve that against a motivated user, and pinning plus obfuscation is cheaper. Use it when the threat is infrastructure between TLS termination and your service, or when a regulation requires end-to-end protection of specific fields. 4. Device Attestation, App Attest and Application Integrity TL;DR — Your server cannot trust anything a client says about itself, including "I am your genuine app". Apple's App Attest (DeviceCheck framework, iOS 14+) lets the Secure Enclave produce cryptographic proof, signed by Apple, that a request came from a genuine copy of your app on a genuine Apple device. Use it to gate sensitive endpoints, combine it with server-side risk scoring, and never hard-block users whose devices simply don't support it. The problem attestation solves API keys, client IDs, user-agent strings and obfuscated secrets all live inside your app, so anyone can copy them into a script. That is how bots create fake accounts, farm referral bonuses, scrape catalogs and abuse free tiers. Attestation flips the model: instead of the client knowing a secret, the client proves something using a key that hardware generated and Apple vouched for. The DeviceCheck family API What it gives you Typical use DCDevice (iOS 11+) An opaque token your server exchanges with Apple to read/write 2 bits per device, plus a timestamp. Survives reinstall and reset. "Has this device already claimed the free trial / promo?" DCAppAttestService (iOS 14+) A Secure Enclave key attested by Apple, then signed assertions on requests. Prove requests come from your genuine, unmodified app App Attest fraud metric Apple-reported estimate of how many attested keys a device has created recently. Detect device farms and reinstall abuse How App Attest works There are two phases. Attestation happens once per key (typically once per install). Assertion happens on each sensitive request afterwards. Phase 1: Attestation (once) App checks DCAppAttestService.shared.isSupported. App calls generateKey(); the Secure Enclave creates a P-256 key and returns its keyId. App fetches a one-time random challenge from your server. App calls attestKey(keyId, clientDataHash: SHA256(challenge)). Apple's servers sign a certificate for the key that encodes your Team ID + bundle ID and that this is an unmodified App Store / TestFlight / development build. App sends the attestation object, keyId and challenge to your server, which verifies it and stores the public key and a counter. Phase 2: Assertion (per request) App builds the request and computes clientDataHash = SHA256(requestData), where requestData includes a server challenge or timestamp. App calls generateAssertion(keyId, clientDataHash:). Server verifies the signature with the stored public key and checks the counter increased. No call to Apple is needed. Client implementation // AppAttestManager.swift import DeviceCheck import CryptoKit actor AppAttestManager { private let service = DCAppAttestService.shared private let store = KeychainStore(service: "com.example.app.attest", accessibility: kSecAttrAccessibleAfterFirstUnlockThisDeviceOnly) private let api: AttestAPI // your networking layer init(api: AttestAPI) { self.api = api } var isSupported: Bool { service.isSupported } /// Call once (e.g. after login). Returns the attested keyId. func attestIfNeeded() async throws -> String { if let existing = try? store.data(for: "keyId"), let keyId = String(data: existing, encoding: .utf8) { return keyId } guard service.isSupported else { throw AttestError.unsupported } let keyId = try await service.generateKey() let challenge = try await api.fetchChallenge() // server nonce let clientDataHash = Data(SHA256.hash(data: challenge)) do { let attestation = try await service.attestKey(keyId, clientDataHash: clientDataHash) try await api.registerAttestation(keyId: keyId, attestation: attestation, challenge: challenge) try store.set(Data(keyId.utf8), for: "keyId") return keyId } catch let error as DCError where error.code == .serverUnavailable { // Apple's service temporarily unavailable: retry later with the SAME keyId throw AttestError.retryLater } } /// Produce an assertion for a sensitive request body. func assertion(for requestData: Data) async throws -> (keyId: String, assertion: Data) { let keyId = try await attestIfNeeded() let clientDataHash = Data(SHA256.hash(data: requestData)) do { let assertion = try await service.generateAssertion(keyId, clientDataHash: clientDataHash) return (keyId, assertion) } catch let error as DCError where error.code == .invalidKey { // Key lost (e.g. restored to a new device). Start over. try store.delete("keyId") return try await assertion(for: requestData) } } } enum AttestError: Error { case unsupported, retryLater } Add the App Attest capability in Xcode. It creates the entitlement com.apple.developer.devicecheck.appattest-environment; set it to production for App Store and TestFlight builds. With development, attestations come from Apple's sandbox environment and your server must accept the development AAGUID only in non-production backends. Server verification of the attestation The attestation object is CBOR-encoded with format apple-appattest. Your server must check every one of these, in order. Skipping any single step usually breaks the whole scheme: # Check 1 Decode CBOR; extract x5c certificate chain, receipt and authData. 2 Validate the chain up to Apple App Attestation Root CA (download from Apple's Private PKI page and embed it). 3 Compute `nonce = SHA256(authData \ 4 SHA-256 of the leaf certificate's public key must equal the {% raw %}keyId the app sent. 5 rpIdHash in authData must equal SHA256("TEAMID.com.example.app"). 6 The counter in authData must be 0. 7 AAGUID must be appattest followed by seven zero bytes (production) or appattestdevelop (development). 8 Credential ID in authData must equal the keyId. 9 Challenge must be one you issued, unexpired and unused. Store public key, counter and receipt against the user. Server verification of each assertion # Check 1 Decode CBOR; extract signature and authenticatorData. 2 Recompute clientDataHash from the request data the server actually received. 3 `nonce = SHA256(authenticatorData \ 4 {% raw %}rpIdHash must match your App ID hash again. 5 Counter must be strictly greater than the stored counter; then store the new value. 6 The embedded challenge / timestamp in requestData must be fresh and unused. Don't hand-roll the CBOR and X.509 parsing if you can avoid it. Maintained open-source verifiers exist for Node, Go, Python, Java and Swift on the server (search for "app attest server validation" for your stack), and Apple's documentation article Validating apps that connect to your server is the authoritative specification. Rolling out App Attest safely Don't block on unsupported devices. Simulators, some older devices and some extension contexts return isSupported == false. Treat them as higher risk, not as attackers. Ramp gradually. Apple asks large apps to roll out attestation to a percentage of users over time, since the attestation step calls Apple's servers. Assertions are local and cheap. Start in monitor mode. Verify and log for a few weeks before enforcing, so you learn your real failure rate. Gate by value. Require assertions on signup, login, payments, rewards, and other abuse-prone endpoints, not on every image request. Use the receipt. Periodically refresh the attestation receipt with Apple to read the fraud metric for that device and feed it into your risk engine. What App Attest does not do App Attest proves the app binary is genuine and the key is hardware-bound. It does not prove the device is not jailbroken, and it cannot stop an attacker who drives your real, unmodified app on a real device (for example through UI automation on a device farm). That is why the fraud metric, rate limiting and behavioural signals still matter. Other application integrity signals Signal Value Caveat StoreKit 2 AppTransaction (iOS 16+) JWS signed by Apple proving the app was obtained from the App Store; verify on server About purchase/ownership, not runtime tampering Jailbreak heuristics Detect common file paths, writable system dirs, injected dylibs Easily bypassed; use only as a risk signal Debugger / hooking detection Check P_TRACED via sysctl, scan loaded images for Frida Arms race; raises cost, never decisive Code obfuscation Slows reverse engineering of logic and endpoints Not a substitute for server-side checks Putting it all together A sound architecture for a high-value app looks like this: the app attests its Secure Enclave key with App Attest on first sensitive use; the server registers that key against the user; sensitive requests carry an App Attest assertion (or a Secure Enclave signature as in section 3); secrets live in the Keychain with ThisDeviceOnly accessibility (section 2); transport uses ATS, with SPKI pinning where the threat model justifies it (section 1); and the server combines all of these signals with rate limiting and behavioural analysis instead of trusting any single one. The one rule behind all four sections: Every client-side control can be bypassed by someone who owns the device. Client-side security exists to protect honest users and to raise the cost of abuse. The final decision about what to trust must always be made on the server. Don't want to write all this Keychain code yourself? I packaged the storage patterns from section 2 (and more) into SecureStorageKit, a free, MIT-licensed Swift package with no third-party dependencies: typed Keychain storage, biometric-protected items, AES-GCM with key rotation, Secure Enclave signing and a token store. More on it in my next post. References for further reading Apple Developer Documentation: Establishing your app's integrity and Validating apps that connect to your server (DeviceCheck / App Attest). Apple Developer News: Identity Pinning: How to configure server certificates for your app (NSPinnedDomains). Apple Platform Security Guide: Keychain data protection, Secure Enclave and Data Protection classes. Apple Developer Documentation: CryptoKit (SecureEnclave, HPKE, AES.GCM, P256). OWASP Mobile Application Security Verification Standard (MASVS) and Testing Guide (MASTG). RFC 9180: Hybrid Public Key Encryption. Over to you: has SSL pinning ever caused an outage for your team? Did you fix it with backup pins, or remove pinning altogether? I'd love to hear real-world stories in the comments.
This is a summary aggregated from Dev.to. Read the complete article on the original site:
Read full article at Dev.to