The Keychain is an encrypted database of small secrets that the system keeps for you: apps reach it only through the SecItem API, and every item is encrypted with keys protected by the device passcode and the Secure Enclave.
Who holds it: the database lives outside your app container and is served by the securityd daemon. Your process sends query dictionaries to SecItemAdd, SecItemCopyMatching, SecItemUpdate and SecItemDelete.
How it is encrypted: each item has its own AES-256 key, wrapped by a class key that is in turn protected by the passcode and the device's hardware key. That is why a class such as WhenUnlocked can make an item readable only while the phone is unlocked.
What you choose: kSecAttrAccessible (WhenUnlocked, AfterFirstUnlock, …ThisDeviceOnly for no backup or iCloud transfer). Items outlive app deletion, so a reinstall can find the old token.
Who can read an item: only apps whose signed entitlements list its access group (it starts with your Team ID); another developer's app cannot query it.
**Item types (kSecClass)**: kSecClassGenericPassword for tokens and secret blobs, kSecClassInternetPassword for server credentials, kSecClassKey, kSecClassCertificate and kSecClassIdentity (certificate plus private key).
Small values only: tokens, passwords, keys. A large file is encrypted on disk, and only its key goes into the Keychain.
iOSMiddle
Security, Privacy & Keychain
Data protection classes, ATS and TLS, certificate pinning, biometrics, jailbreak detection, secrets in the build, permissions, ATT and location
Questions closed
0 / 20
- 0Review
- 2Asked often
Questions
No. Everything in the .ipa ships to every user: Info.plist is a readable file in the bundle and string constants sit in the binary, so the key is extracted in minutes. A key that grants real power stays on your server, and the app calls your backend instead.
How it leaks: an .ipa is a zip archive: unzip it and open Info.plist, or run strings on the binary. On a jailbroken phone the value can also be read from memory.
**.xcconfig solves a different problem**: a git-ignored Secrets.xcconfig or a CI variable keeps the key out of the repository, but the build still copies the value into the app.
Obfuscation: XOR-ing or splitting the string only slows an attacker down; it is a speed bump, not protection.
Backend proxy: the vendor key lives only on your server. The app sends its own session token, and the server forwards the call and applies per-account quotas. Changing the key is a server deploy, with no new build.
When a key must ship (a maps SDK, analytics): restrict it at the vendor to your bundle ID, set quotas and alerts, and treat it as public.
Proving the caller is your app: App Attest lets the backend reject requests from a script that copied the key.
Create an LAContext, call canEvaluatePolicy to check that biometrics are available and enrolled, then call evaluatePolicy with a reason string. Face ID also needs NSFaceIDUsageDescription in Info.plist.
Policies: .deviceOwnerAuthenticationWithBiometrics accepts only Face ID or Touch ID and leaves the fallback to you; .deviceOwnerAuthentication falls back to the device passcode by itself.
Availability errors: biometryNotAvailable (no hardware, or the user denied Face ID when the system asked once), biometryNotEnrolled, and biometryLockout after too many failures, until the passcode is entered.
Outcomes: userFallback when the user taps your fallback button, userCancel, systemCancel when the app goes to the background, authenticationFailed.
**biometryType** tells Face ID from Touch ID for button titles; it is filled in only after canEvaluatePolicy has been called.
The limit: the result is a Bool inside your process. Fine for hiding a screen, not for protecting a secret: for that the Keychain item itself must require biometrics through SecAccessControl.
The app declares a purpose string in Info.plist, and the system asks the user once, on the first request. Without NSCameraUsageDescription the app is killed at that first access; after Don't Allow the prompt never returns, and only the user can turn access back on in Settings.
Missing key: the process is terminated with "This app has crashed because it attempted to access privacy-sensitive data without a usage description". App Review also rejects vague strings: say what the feature does with the camera.
Statuses: .notDetermined (you may ask), .authorized, .denied, and .restricted (parental controls or a device profile, which the user cannot change either).
Ask in context: when the user taps the feature, not at launch.
After Don't Allow: a new request returns false with no prompt. Explain what is missing, link to UIApplication.openSettingsURLString, and keep the rest of the app working.
Re-check, do not cache: the user can flip the permission in Settings at any time, so read the status at each launch and foreground.
TLS gives a request encryption, integrity and the server's identity. In the handshake the client and server agree on a version, a cipher and a shared key through ECDHE, and the server proves it holds the certificate's private key; the client checks that certificate before it sends the request.
The handshake (TLS 1.3, one round trip): the client sends versions, ciphers and a key share; the server answers with its choice, its own key share, its certificate chain and a signature over the handshake made with the certificate's private key.
Forward secrecy: the session keys come from throwaway ECDHE keys, so a server key stolen later cannot decrypt recorded traffic. ATS requires it. After that every record is encrypted and authenticated, so a modified byte is rejected.
**What URLSession checks**: a chain to a root the device trusts, the validity dates and the host name in the Subject Alternative Name. A failure gives an error such as -1202.
Where it stops: a root the user installed and trusted, such as a debugging proxy's, passes too; pinning closes that gap. A trust delegate that returns .useCredential without calling SecTrustEvaluateWithError switches the checks off.
Each file is encrypted with a key from a class that decides when it can be read: complete only while unlocked, completeUnlessOpen keeps already open files usable after lock, completeUntilFirstUserAuthentication (the default) after the first unlock since boot, none always.
**complete: the class key is discarded about 10 seconds after the device locks, and reads fail with a permission error. Right for statements and medical documents.
completeUnlessOpen: a file opened before the lock stays usable and new files can still be created; built for a download that must finish on a locked phone.
completeUntilFirstUserAuthentication: the default. It protects a phone that was rebooted and not unlocked yet, nothing more: background code after the first unlock reads it freely.
none: protected only by the device key; for files a launch before the first unlock must read.
Setting it**: per write (.completeFileProtection), with FileManager.setAttributes, or app-wide through the Data Protection capability.
The trap: code that runs while the phone is locked gets an error on a complete file. Check isProtectedDataAvailable and close files on protectedDataWillBecomeUnavailableNotification.
The token is saved as kSecAttrAccessibleWhenUnlocked, so a background sync on a locked phone gets errSecInteractionNotAllowed. readToken() turns that error into nil, and the app signs out a user whose token is perfectly valid.
Why it happens: a WhenUnlocked item can be decrypted only while the device is unlocked. Silent pushes and BGAppRefreshTask usually run with the screen locked, so shortly after the lock the read fails with -25308 there and only there, which is why it looks random.
Two mistakes compound: the wrong accessibility class makes the read fail, and collapsing every non-success status into nil makes "cannot read right now" look like "there is no token".
Fix the class: store a token that background code needs as kSecAttrAccessibleAfterFirstUnlockThisDeviceOnly. Items already on users' phones keep their old class, so migrate them with SecItemUpdate on a foreground launch.
Fix the error handling: only errSecItemNotFound means signed out. errSecInteractionNotAllowed still happens after a reboot before the first unlock, even with the new class, so treat it as "try again later".
Same trap elsewhere: widgets, notification service extensions and background URLSession completion handlers also run on a locked phone.
ATS rejects the TLS handshake because the server is below Apple's minimum: TLS 1.2 or later, a forward-secret ECDHE cipher, and a certificate signed with SHA-256 on an RSA key of 2048+ bits or an ECC key of 256+ bits. Browsers are more lenient, so they connect.
Diagnose on a Mac: nscurl --ats-diagnostics --verbose https://api.example.com retries the connection with each ATS setting relaxed and shows which exception would make it pass.
Read the error: -1200 is NSURLErrorSecureConnectionFailed; the underlying -9802 is a fatal TLS alert. Setting the CFNETWORK_DIAGNOSTICS environment variable to 3 in the scheme logs the handshake details.
Fix the server first: enabling TLS 1.2/1.3 and ECDHE suites is a configuration change on the load balancer and helps every client.
A narrow exception if you cannot: under NSExceptionDomains, NSExceptionMinimumTLSVersion (for example TLSv1.0) or NSExceptionRequiresForwardSecrecy set to NO, for that one domain only. App Review may ask you to justify it.
Where ATS does not reach: it applies to URLSession and WKWebView, not to lower-level APIs such as the Network framework or raw sockets. A library on those bypasses both the check and its protection.
On a pin mismatch the delegate falls back to .performDefaultHandling, so any certificate that a trusted CA signed is accepted and pinning protects nothing. The pin check only decides which branch runs.
Why it looks fine: the real server matches the pin and the app works. The hole shows only with another valid chain, such as a proxy whose CA the user installed or a certificate misissued by a public CA.
What default handling means: the system runs its ordinary trust evaluation, and a chain ending in a root from the trust store succeeds. That is the very check pinning is meant to tighten.
The fix: cancel on mismatch with .cancelAuthenticationChallenge; the request fails with NSURLErrorCancelled (-999).
Keep the system check: call SecTrustEvaluateWithError first, so expiry and hostname are still verified. Pinning narrows trust, it does not replace it.
Test it: run a proxy such as mitmproxy with its CA trusted on the device; the request must fail.
SHA256.hash gives a fingerprint, AES.GCM.seal (or ChaChaPoly) encrypts and detects tampering in one step, and HMAC proves who produced a message. The SymmetricKey lives in the Keychain, never in the bundle or next to the encrypted file.
A hash is not encryption: SHA-256 is one-way and needs no key; use it for integrity checks and cache keys.
Authenticated encryption: AES-GCM and ChaChaPoly add a tag, and open throws if any byte changed. Raw AES-CBC from CommonCrypto has no such check and is easy to misuse.
Nonce: seal picks a random nonce for you, and combined packs nonce, ciphertext and tag into one blob to store. Reusing a nonce with the same key breaks GCM, so do not pass your own without a reason.
Keys from secrets: HKDF derives keys from high-entropy input such as a key-agreement result. A user's password needs a slow KDF such as PBKDF2 (CCKeyDerivationPBKDF in CommonCrypto).
Where the key goes: generate it once and save its bytes as a kSecClassGenericPassword item with a ThisDeviceOnly class. The file is worthless without it, and deleting the item is an instant wipe.
The unified log redacts interpolated values you do not mark .public: strings and objects appear as <private>, while numbers and booleans are public by default. Tokens, passwords and one-time codes must not be logged at all, redacted or not.
Who can read the log: anyone with the unlocked phone and a Mac running Console, a sysdiagnose the user sends to support, and diagnostic reports. Private values stay redacted there.
When private values show: with Xcode's debugger attached, or when a logging configuration profile enables private data. .private protects shipped logs, not your own debug session.
**.private(mask: .hash): replaces the value with a hash, so you can see that two lines refer to the same account without seeing the account.
print and NSLog**: print goes to stdout, visible under a debugger, with no privacy markers at all. NSLog logged everything in the clear before iOS 26; on iOS 26 its dynamic string arguments are redacted to <private> in the unified log (Xcode's console and stdout are unaffected), and Apple points to os_log or Logger for unredacted values, so route everything through Logger and strip print from release builds.
What to log instead: IDs, status codes, durations and error domains; not request bodies, card numbers, precise location or anything a user typed.
Third-party SDKs: crash reporters and analytics collect breadcrumbs and custom keys; the same rules apply to what you hand them.
iOS has no flag that blocks screenshots, so you cover the content yourself: show a privacy overlay as soon as the scene becomes inactive, before the system takes the app-switcher snapshot, and hide sensitive views while sceneCaptureState is .active.
Snapshot timing: the system captures the UI after the app moves to the background. Covering it in sceneWillResignActive (.inactive in SwiftUI) also hides balances when Control Center or a call banner comes down.
Recording and mirroring: traitCollection.sceneCaptureState (iOS 17+, UIScreen.isCaptured before that) reports screen recording, AirPlay and mirroring. Hide card numbers and balances while it is .active.
Screenshots: userDidTakeScreenshotNotification arrives after the screenshot is saved. You can warn the user or log it, not prevent it.
Clipboard: copy codes with UIPasteboard.general.setItems(_:options:) using .localOnly (no Universal Clipboard) and .expirationDate.
Keyboards: secure fields (isSecureTextEntry, SecureField) switch off third-party keyboards and suggestions. To refuse custom keyboards everywhere, return false from application(_:shouldAllowExtensionPointIdentifier:) for .keyboard.
ATT is the permission an app needs before it tracks the user across other companies' apps and websites, which includes reading the IDFA. The prompt appears only while the app is active and only once; after a decline the IDFA is all zeros, and attribution moves to SKAdNetwork or AdAttributionKit.
What counts as tracking: linking your app's data with other companies' data for targeted ads or ad measurement, or passing it to a data broker. Your own analytics, and ads based only on your own data, are not tracking.
The request: ATTrackingManager.requestTrackingAuthorization() plus NSUserTrackingUsageDescription. Called while the app is not active, for example from didFinishLaunching, it returns at once with no prompt.
When to ask: after onboarding, with the scene active, optionally behind your own explanation screen. Rewards for allowing, or a fake system alert, get the app rejected.
After a decline: advertisingIdentifier is 00000000-0000-0000-0000-000000000000, and fingerprinting or matching hashed emails with an ad network is forbidden too. identifierForVendor still works for your own analytics.
Attribution without the IDFA: SKAdNetwork and AdAttributionKit report installs and coarse conversion values to the ad network, aggregated, delayed and with no user id.
When In Use delivers locations while the app is in use; Always also lets the system relaunch the app for geofences, visits and significant changes. With Precise Location off, the location is approximate: a circle of about 5 km radius, recomputed about four times an hour.
When In Use in the background: updates the app started on screen continue with the location background mode and allowsBackgroundLocationUpdates (or a CLBackgroundActivitySession on iOS 17+).
Always: for region monitoring, visits and significant-location changes, which relaunch the app. Ask for When In Use first and upgrade later, when a feature needs it: the upgrade request works only once.
Info.plist: NSLocationWhenInUseUsageDescription, plus NSLocationAlwaysAndWhenInUseUsageDescription for Always, or the request fails at once.
Precise Location off (.reducedAccuracy): the radius is a couple of km in a dense city and 10 km or more in the countryside. Region monitoring stops working; significant changes and visits continue with coarse coordinates. Show a city-level result, not a pin in the wrong street.
When precision is required: requestTemporaryFullAccuracyAuthorization(withPurposeKey:) with a purpose string from NSLocationTemporaryUsageDescriptionDictionary, at the moment the feature needs it; it lasts for the session.
It catches only an old rootful jailbreak with Cydia on an unmodified copy of the app. Modern rootless jailbreaks keep their files under /var/jb, and hiding tweaks hook fileExists and canOpenURL so every check returns false.
Paths go stale: rootless jailbreaks such as Dopamine and palera1n do not touch /Applications or /bin, and every new jailbreak changes the list.
**canOpenURL**: returns false for any scheme not listed in LSApplicationQueriesSchemes, so without that entry this check never fires.
The write probe: a reasonable test that the sandbox is intact, but it is hooked just as easily as the rest.
One patch defeats all of it: the result is a single Bool from a single function; Frida or a binary patch makes it return false.
**exit(0): looks like a crash, punishes users hit by a false positive, and points an attacker straight at the function to patch.
What to do instead**: send the result to the server as one risk signal, next to an App Attest assertion. Keep secrets in the Keychain or the Secure Enclave, and let the server decide risky operations such as a transfer or a new device, with step-up verification when the signals look bad.
The Secure Enclave is a separate coprocessor that generates and uses keys without ever revealing them to iOS. With CryptoKit you create a SecureEnclave.P256 key, store its encrypted handle, and ask the enclave to sign or to perform key agreement.
What you get: a P-256 key for Signing (ECDSA) or KeyAgreement (ECDH). The classic curve is only P-256 (no RSA, no raw AES): to encrypt data, derive a symmetric key from key agreement. iOS 26 adds enclave-backed ML-KEM and ML-DSA keys.
**dataRepresentation**: not the key itself but an encrypted blob that only this device's enclave can use. Keep it in the Keychain and restore the key with init(dataRepresentation:).
Access control: .privateKeyUsage is required; adding .biometryCurrentSet makes every signature need Face ID, and the key becomes unusable when the enrolled biometrics change.
No export, no backup: a new phone means a new key and a new registration with the server. Design for that from the start.
Availability: SecureEnclave.isAvailable is false in the Simulator, so tests need a protocol around the key and a software fallback.
Typical use: device binding: register the public key at sign-in, then sign server challenges, so a stolen refresh token alone is not enough to act as the user.
They set what the system demands before it decrypts the item: .userPresence accepts biometrics or the passcode, .biometryAny any enrolled face or finger, .biometryCurrentSet only the set enrolled when the item was saved; after a new enrollment that item can never be read again.
evaluatePolicy hands your code a Bool, and a hook can flip it. With SecAccessControl the item is decrypted only after a successful match, so there is no branch to patch.
Choosing the flag
**.biometryCurrentSet**: for banking: someone who learned the passcode cannot add their own face and open the account. The cost is recovery: after re-enrollment the read fails (typically errSecItemNotFound), so do a full sign-in and save a new item.
**.biometryAny: survives a newly added finger; good for convenience unlock.
.userPresence**: passcode fallback built in; it stops a stranger, not someone who knows the passcode.
Flags combine, for example [.biometryCurrentSet, .or, .devicePasscode].
Practical details
Accessibility goes into SecAccessControlCreateWithFlags; you cannot also set kSecAttrAccessible. With kSecAttrAccessibleWhenPasscodeSetThisDeviceOnly the item is deleted if the user turns the passcode off.
Adding the item does not prompt; reading it shows Face ID and blocks the calling thread until the user answers, so never read on the main thread.
Pass an LAContext in kSecUseAuthenticationContext to set the prompt text and to reuse an authentication that just happened instead of asking twice.
A cancelled prompt returns errSecUserCanceled (-128): stay on the lock screen, do not treat it as a sign-out.
Ship the next key's pin before you need it: every release pins the current key and a pre-generated backup, so the server can switch to the backup and old builds keep connecting. The rotation is planned around the oldest app version still in use, not the certificate's expiry date.
Before the first release
Generate the backup key pair now and keep its private key offline (an HSM or a vault). Pin the SPKI hashes of both keys with NSPinnedDomains (iOS 14+), so a renewal on the same key changes nothing.
Compute the pin from the certificate: openssl x509 -in cert.pem -pubkey -noout | openssl pkey -pubin -outform der | openssl dgst -sha256 -binary | base64.
If the key will not be reused, pin the issuing CA (NSPinnedCAIdentities) instead.
The rotation
Before switching, check what share of active installs carries the backup pin, and require a minimum version for the rest. The version check must not depend on the pinned connection, or the old builds will never see the update prompt.
Switch the server to the backup key, then generate a new backup and ship its pin in the next release, so there are always two pins in the field.
A compromised key is the same procedure, just immediate: that is why the backup is kept offline.
Safety nets
Report pin failures to a separate endpoint that is not pinned, so a mistake shows up in minutes, not in reviews.
A remote "disable pinning" flag is a backdoor for whoever controls the network, unless the config is signed with a key built into the app.
Both let your server ask Apple about the caller. DeviceCheck keeps two bits per device for your team that survive a reinstall; App Attest proves that a request was signed by a genuine instance of your app on a real Apple device.
DeviceCheck
DCDevice.current.generateToken() returns a short-lived token. Your server sends it to Apple to read or set two bits and a timestamp for that device.
Typical use: "this device already took the free trial" or "flagged for fraud", without an identifier the user can reset by deleting the app.
It says nothing about whether the app is genuine.
App Attest
The app creates a Secure Enclave key with generateKey(), and attestKey asks Apple to certify it together with the hash of a one-time server challenge.
The server verifies the chain to Apple's App Attest root, your Team ID and bundle ID, the challenge and the counter, then stores the public key for that install.
Each sensitive request then carries generateAssertion over the request and a fresh challenge; the server checks the signature and that the counter grew, which stops replays.
Limits: isSupported is false in the Simulator and some extensions, attestation is rate-limited, so attest once per install and roll out gradually, and the com.apple.developer.devicecheck.appattest-environment entitlement selects development or production.
What neither does
They do not authenticate the user, and a jailbroken device can still run your genuine app and drive it. Treat them as signals for server-side decisions, next to the user's session.
Keep the real credential behind device-bound Secure Enclave keys and make the server the judge: Face ID unlocks a key through SecAccessControl, the PIN is checked by the server with an attempt limit, and the session is a short-lived token kept only in memory.
Enrollment
After a full sign-in (password plus SMS or another factor), create two enclave keys: a device key with .privateKeyUsage only, and a biometric key with .biometryCurrentSet. Register both public keys together with an App Attest attestation whose clientDataHash covers the server challenge and both public keys, so the server knows a genuine app instance presented them; Apple certifies only the App Attest key, not the enclave keys.
Never store the PIN on the device. A 6-digit PIN has a million values: if it decrypts anything locally, a copied file is brute-forced in seconds. The server keeps a slow hash of it, bound to this device.
Unlock
Face ID: the biometric key signs a server challenge, and the server issues a short-lived access token. After a new face is enrolled the key stops working: fall back to the PIN, then offer to turn Face ID on again.
PIN: the app sends the PIN with a signature from the device key. The server counts failures per device and after five revokes the binding, which forces a full sign-in.
Session and auto-lock
The access token lives in memory only, for minutes; there is no long-lived token on disk that opens the account without one of the two proofs.
Note the time when the scene goes to the background; on return after more than 60 seconds, or after the process was killed, drop the in-memory token and show the lock screen. The server's token lifetime is the real limit, because the user can change the device clock.
Cover the UI before the app-switcher snapshot, and make "remove this device" delete both keys and every Keychain item while the server revokes the binding.