Keychain and security
Where the token lives, what each Keychain accessibility class trades away, and the security questions that filter senior interviews.
By the end you will be able to
- Answer "where do you store X" for every class of app data, with reasons
- Choose Keychain accessibility classes against real usage patterns
- Extend the same trade-offs to files with Data Protection classes and backup exclusion
- Explain biometric protection, ATS and pinning at the depth interviews probe
The interview filter question: "where do you store the user's auth token, and why not UserDefaults?" What is the complete answer?
The storage decision table
The senior skill is a policy, not one fact. For every kind of data, an answer and a reason:
| Data | Store in | Why |
|---|---|---|
| auth tokens, refresh tokens | Keychain | secret; encrypted at rest; survives reinstall* |
| passwords (if you must) | Keychain | as above — and prefer tokens: a leaked token is app-scoped and revocable server-side; a leaked password is the user's reused identity everywhere, revocable nowhere |
| user preferences, flags | UserDefaults | not secret; fast; plist is fine |
| user content | SwiftData / files in the container | Data Protection encrypts at rest with the passcode |
| large media | files (Caches if re-downloadable) | purgeable location for purgeable data |
| API keys for your backend | not on the client | anything shipped in the binary is extractable — gate per-user on the server |
| entitlement/subscription state | StoreKit's truth (+ server) | the caching lesson's rule: never re-cache another system's source of truth |
*The asterisk is a real interview follow-up: Keychain items survive app deletion (historically and still under current behaviour) — which is convenient for "reinstalled and still signed in", and a compliance surprise if your privacy story claims deletion removes everything. Handle it deliberately: on first launch after install, decide whether stale credentials should be honoured or purged.
Keychain: the API and the accessibility decision
The raw API is C-flavoured (SecItemAdd, SecItemCopyMatching with CFDictionary queries); most teams wrap it once in a small typed store — a KeychainStore with get/set/delete by key — and never touch Security.framework again. What cannot be wrapped away is the accessibility class, because it is a real trade-off per item:
kSecAttrAccessible… | Readable when | Use for |
|---|---|---|
WhenUnlocked | device unlocked | default for foreground-only secrets |
AfterFirstUnlock | any time after first unlock post-boot | tokens needed by background work (sync, pushes) |
WhenPasscodeSetThisDeviceOnly | unlocked, passcode required, never migrates | high-value secrets |
Two dimensions hide in those names. When decides background access: a background refresh task running while the phone is locked in a pocket cannot read a WhenUnlocked item — the classic "sync works in testing, fails overnight" bug, because testing happens with the device unlocked. ThisDeviceOnly decides migration: without it, items restore onto new devices via encrypted backup; with it, the key material never leaves this device's hardware. Refresh tokens for a sync engine: AfterFirstUnlockThisDeviceOnly — readable by background work, never migrating to a device you didn't authorise.
The mapping is small, but saying why each lands where it does — background readability versus exposure window versus migration — is precisely the texture interviews listen for.
Files have protection classes too
The storage table sent "user content" to files in the container with a one-line reassurance — Data Protection encrypts at rest with the passcode. The senior follow-up is that this encryption comes in classes, per file, and they mirror the Keychain dimensions you just learned:
FileProtectionType | Readable when | Keychain analogue |
|---|---|---|
.complete | device unlocked | WhenUnlocked |
.completeUnlessOpen | must be opened unlocked; a file already open stays usable after lock, and new files can be created while locked | — (the upload/download case) |
.completeUntilFirstUserAuthentication | any time after first unlock post-boot | AfterFirstUnlock |
.none | always | avoid — passcode-derived encryption off |
The two facts that carry the interview exchange: the default for files your app creates is .completeUntilFirstUserAuthentication — genuinely encrypted at rest, but readable for the entire post-first-unlock uptime — and upgrading one sensitive file is a single line at write time, with exactly the WhenUnlocked trade-off: background work can no longer read it while the device is locked in a pocket.
// Upgrade at write time — cached statements, exported reports, KYC documents:
try statementPDF.write(to: url, options: .completeFileProtection)
// Or retro-fit a file that already exists:
try FileManager.default.setAttributes(
[.protectionKey: FileProtectionType.complete],
ofItemAtPath: url.path
)
A banking app's cached transaction list and statement PDFs deserve that line; a re-downloadable image cache does not — .complete files being unreadable while locked is precisely the "sync works in testing, fails overnight" bug from the Keychain table, now on the file axis.
The migration dimension exists for files too, spelled backups: everything in Documents rides along in device backups, and keeping a file out is an explicit act —
var resourceValues = URLResourceValues()
resourceValues.isExcludedFromBackup = true
try url.setResourceValues(resourceValues) // url must be a var
— the file-system twin of ThisDeviceOnly, and the same compliance thread as the Keychain reinstall asterisk: know where every copy of sensitive data can end up, and be able to say it in the privacy review.
Biometrics: two different jobs
Face ID/Touch ID answers two distinct questions, and conflating them is a common design error:
"Is an authorised user present?" — LAContext.evaluatePolicy shows the biometric prompt and returns a boolean. Fine for UI gating (reveal the balance, open the vault screen). The limitation: it is an if-statement — a compromised process could skip it, because nothing cryptographic depended on the answer.
"Unlock this secret only for an authorised user" — create the Keychain item with SecAccessControl requiring .userPresence (or .biometryCurrentSet). Now the decryption itself demands biometric success; there is no boolean to skip. .biometryCurrentSet adds the sharp edge worth quoting: enrolling a new fingerprint invalidates the item — the threat being someone who briefly has your phone and adds their own finger.
The rule: gate screens with LAContext; protect secrets with access control on the item.
The network side: ATS and pinning
App Transport Security is the default that forbids plaintext HTTP and weak TLS — and the thing it refuses is worth picturing: over plaintext HTTP, anyone on the same café Wi-Fi reads your user's bearer token mid-flight and replays it. Interception is the threat; App Review's scrutiny is merely the symptom you meet first. The interview point is posture: exceptions (NSAllowsArbitraryLoads) are a per-domain, justified last resort — not a first-week "make the warning go away" commit that ships forever.
Certificate pinning — trusting only your server's known key rather than any CA-issued cert — defeats interception by a rogue or compromised authority. The senior answer is a trade-off, not a yes: pin the SPKI public key (not the leaf cert), ship a backup pin, and have a rotation story — because a pin your server outrotates bricks the app's networking for every installed copy until users update. High-sensitivity domains (banking, health): usually yes. A content app: often the operational risk outweighs the threat. Saying both halves is the pass.
And the client-secret rule one more time, because interviews circle back to it: anything in the binary is public. API keys, obfuscated or not, are readable from the IPA. The pattern that works: the client authenticates users (tokens from your auth flow); the server holds service secrets and enforces per-user authorisation.
Overnight background sync fails with "item not found" reading the refresh token, though sync works all day. The token was stored with default accessibility. The diagnosis and fix?
Write the storage policy
Encode the decision table: given data properties, return where it lives — the complete interview answer as a function.
Model the accessibility gate
Implement the Keychain's readability rule: given an item's class and the device state, can this read succeed? Then trace the overnight-sync bug through it.
Close the page. Write the full storage-and-security answer for a banking-style app as you would say it in an interview: where session vs refresh tokens live (with accessibility classes), which file protection class the cached statements get and why, what biometrics protect and via which mechanism, the pinning decision with its rotation caveat, and the one thing that must never ship in the binary. Then check yourself against the lesson.
You can now:
- Give the layered "where does X live" answer with reasons, including the reinstall nuance
- Choose accessibility classes against background access and migration
- Pick file protection classes and backup exclusion with the same dimensions
- Split biometric gating from biometric-protected decryption
- Argue ATS posture and pinning as trade-offs with operational costs
Next up: Core Data — the persistence stack your next legacy codebase runs on.