Persistence and networking · advanced

Keychain and security

Where the token lives, what each Keychain accessibility class trades away, and the security questions that filter senior interviews.

13 min read12 min practice0/2 exercises6 recall cards

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
Guess firstAnswering before you read makes the explanation stick — even when you get it wrong.

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:

DataStore inWhy
auth tokens, refresh tokensKeychainsecret; encrypted at rest; survives reinstall*
passwords (if you must)Keychainas 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, flagsUserDefaultsnot secret; fast; plist is fine
user contentSwiftData / files in the containerData Protection encrypts at rest with the passcode
large mediafiles (Caches if re-downloadable)purgeable location for purgeable data
API keys for your backendnot on the clientanything shipped in the binary is extractable — gate per-user on the server
entitlement/subscription stateStoreKit'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 whenUse for
WhenUnlockeddevice unlockeddefault for foreground-only secrets
AfterFirstUnlockany time after first unlock post-boottokens needed by background work (sync, pushes)
WhenPasscodeSetThisDeviceOnlyunlocked, passcode required, never migrateshigh-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 accessibility decision, encoded — usage pattern in, class + rationale out.

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:

FileProtectionTypeReadable whenKeychain analogue
.completedevice unlockedWhenUnlocked
.completeUnlessOpenmust be opened unlocked; a file already open stays usable after lock, and new files can be created while locked— (the upload/download case)
.completeUntilFirstUserAuthenticationany time after first unlock post-bootAfterFirstUnlock
.nonealwaysavoid — 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.

Check yourself

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

Write the code

Encode the decision table: given data properties, return where it lives — the complete interview answer as a function.

Solution unlocks after 3 attempts

Model the accessibility gate

Write the code

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.

Solution unlocks after 3 attempts
RecallSaved on this device. Never graded.

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.

Checkpoint

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.

How well do you know this now? Rating yourself honestly, then being tested on it, is how you find out where your intuition is wrong.