Persistence and networking · advanced

Keychain and security

Where the auth token lives, and why nowhere else will do. What each Keychain accessibility constant gives up. The same two questions applied to files, biometrics and the network — plus the security questions that filter senior interviews.

20 min read12 min practice0/2 exercises6 recall cards

By the end you will be able to

  • Answer "where do you store X" for every kind of app data, with a reason for each
  • Choose a Keychain accessibility constant by asking when your code actually has to read the item
  • Apply the same two questions to files, using Data Protection classes and backup exclusion
  • Explain biometrics, ATS and certificate 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

First, one word this whole lesson leans on. A token is a short string the server gives your app after the user signs in. Your app sends it with every request, and the server treats it as proof of who is calling. Two kinds show up constantly:

  • An access token (also called a session token) is short-lived and travels on each request.
  • A refresh token is long-lived, and its only job is to obtain a new access token when the old one expires.

Both are secrets. Whoever holds one can act as that user until the server revokes it.

The senior skill here is a policy, not a single fact. For every kind of data an app holds, you should have an answer and a reason:

DataStore inWhy
auth tokens, refresh tokensKeychainsecret; encrypted at rest; survives reinstall*
passwords (if you must)Keychainsame reasons — but prefer tokens. A leaked token belongs to one app and the server can revoke it. A leaked password is the identity the user reuses everywhere, and nobody can revoke that
user preferences, flagsUserDefaultsnot secret; fast; a plain file is fine
user contentSwiftData, or files in the app containerData Protection encrypts files at rest with a key derived from the passcode
large mediafiles (the Caches directory if it can be re-downloaded)a purgeable location for purgeable data
API keys for your backendnot on the client at allanything shipped inside the app can be extracted. Gate it per user on the server
entitlement/subscription stateStoreKit's own answer, plus your serverD2-03's rule: never keep a second copy of another system's source of truth

*The asterisk is a real interview follow-up. Keychain items usually survive app deletion. Delete the app, reinstall it, and yesterday's token is still there.

Apple has never documented that as a guarantee. It is a side effect of how the Keychain is implemented, and Apple's own engineers say on the developer forums not to rely on it. (iOS 10.3 beta briefly changed it to delete-on-uninstall. Too many apps depended on the old behaviour, so the change was rolled back before release.)

Both directions bite, so plan for both. It is convenient when a user reinstalls and is still signed in. It is a compliance problem if your privacy policy says deleting the app removes their data. Make it a decision: on the first launch after an install, either honour the old credentials or delete them on purpose.

Keychain: what it is, and the one decision you cannot delegate

The Keychain is a small encrypted database that iOS runs on your behalf. It is not a file in your app's container. Your app asks the operating system to store or fetch an item, and the operating system decides whether to answer. It is built for small secrets: tokens, passwords, keys and certificates. It is not a general-purpose store, and putting a 5 MB blob in it is a misuse.

Every item you store has an item class, which just means what kind of thing it is. You set it with the kSecClass key. kSecClassGenericPassword covers almost everything an app stores for itself, tokens included. The rest are kSecClassInternetPassword (a password tied to a server and protocol), kSecClassKey, kSecClassCertificate, and kSecClassIdentity (a certificate together with its private key).

Watch that word. "Class" is about to be used again for something completely different, so this lesson always says item class or accessibility constant, never just "class".

The API is C-flavoured. You build a CFDictionary of keys and hand it to SecItemAdd, SecItemCopyMatching, SecItemUpdate or SecItemDelete. Each returns an OSStatus code: errSecSuccess when it worked, errSecItemNotFound when nothing matched, errSecDuplicateItem when the item is already there. Most teams write that once, wrap it in a small typed store with get, set and delete, and never open Security.framework again.

One thing cannot be wrapped away, because it is a real trade-off you make per item: the accessibility constant.

How to read the name of an accessibility constant

An accessibility constant tells iOS when an item is allowed to be decrypted. You set it with the kSecAttrAccessible key when you create the item. If you set nothing, you get kSecAttrAccessibleWhenUnlocked, which Apple's documentation confirms is "the default value for keychain items added without explicitly setting an accessibility constant".

The names look long. They are built from two independent halves. Take kSecAttrAccessibleAfterFirstUnlockThisDeviceOnly apart:

  • kSecAttrAccessible — the shared prefix. Every one of these constants starts with it.
  • AfterFirstUnlock — the when half. The item is readable at any moment after the user has unlocked the device once since the last reboot. It stays readable while the phone sits locked in a pocket. Only a restart takes it away again, until the next unlock.
  • ThisDeviceOnly — the where half. The item never moves to another device. Apple's wording: "Items with this attribute do not migrate to a new device. Thus, after restoring from a backup of a different device, these items will not be present."

Mix and match those two halves and you have the whole set.

The bug the default causes: "sync works in testing, fails overnight"

Here is how the default accessibility constant becomes a real production incident. Follow the sequence:

  1. A developer stores the refresh token and sets no accessibility constant. iOS applies the default, kSecAttrAccessibleWhenUnlocked.
  2. The developer tests background sync at their desk. The phone is in their hand with the screen on, so it is unlocked. The token reads back fine every time. The feature ships.
  3. At 3 a.m., a real user's phone is locked on a bedside table. iOS wakes the app for a background refresh.
  4. The app calls SecItemCopyMatching for the refresh token. The device is locked, so a WhenUnlocked item cannot be decrypted. The call returns errSecItemNotFound — the same code you get when the item genuinely does not exist.
  5. With no token, the app cannot call the server. The sync fails. iOS notices a background task that keeps failing and schedules it less often, so the app also loses the wake-ups it did have.
  6. The user opens the app in the morning. The phone is unlocked now, so the token reads fine and everything syncs immediately.

The result: nobody can reproduce it. Every attempt to reproduce it happens on an unlocked device, and that is the one state in which the bug cannot happen. The report says "sync is late sometimes" and stays open for months.

The fix is one constant. Anything a background task must read has to be stored with kSecAttrAccessibleAfterFirstUnlockThisDeviceOnly, not kSecAttrAccessibleWhenUnlocked. AfterFirstUnlock makes the item readable while the phone is locked in a pocket. ThisDeviceOnly is there because a refresh token that follows a backup onto somebody else's new phone is pure exposure and buys you nothing. This is the constant D1-06 writes Ledger's token bytes with, for exactly this reason.

The constants themselves

Constant (after the kSecAttrAccessible prefix)Readable whenUse it for
WhenUnlockedonly while the device is unlockedforeground-only secrets. This is the default
AfterFirstUnlockany time after the first unlock since the last reboot, locked or notanything background work has to read: sync, push handling
WhenPasscodeSetThisDeviceOnlyonly while unlocked, and only on a device that has a passcodethe highest-value secrets

The first two each have a ThisDeviceOnly twin: WhenUnlockedThisDeviceOnly and AfterFirstUnlockThisDeviceOnly. WhenPasscodeSet has no plain twin. Apple ships only the ThisDeviceOnly version of it.

WhenPasscodeSetThisDeviceOnly has three sharp edges, all straight from Apple's documentation. You cannot store anything in it on a device with no passcode. Turning the passcode off deletes every item stored in it. And these items are not included in backups at all.

So two independent questions decide the constant:

  1. When does my code have to read this? Foreground only, so WhenUnlocked. Background too, so AfterFirstUnlock.
  2. Should this item ever exist on another device? If no, add ThisDeviceOnly.

Answer both out loud and you have said everything there is to say about the choice.

Loading runnable Swift…

The mapping is small. What earns marks is being able to say why each item landed where it did: when your code has to read it, how long it stays decryptable after that, and whether it should ever exist on a second device.

Files have protection classes too

The table above sent user content to files with a one-line reassurance: Data Protection encrypts them at rest. Time to unpack that.

Data Protection is file encryption that iOS applies automatically. Every file gets its own random key. That key is then locked behind a class key, and the class key is derived from the user's passcode combined with an identifier burned into this particular chip. So "encrypted at rest" means something specific: without the passcode, and without this exact device, the bytes are noise.

You choose a Data Protection class per file. The class decides when the class key is available, which decides when the file can be opened. These mirror the Keychain constants you just read, almost one for one:

FileProtectionTypeReadable whenKeychain equivalent
.completeonly while the device is unlockedWhenUnlocked
.completeUnlessOpenthe file must be opened while unlocked. A file already open stays usable after the screen locks, and new files can still be created while locked— (this is the upload and download case)
.completeUntilFirstUserAuthenticationany time after the first unlock since the last rebootAfterFirstUnlock
.nonealwaysavoid. No passcode-derived encryption

Two facts carry the interview exchange.

The first is the default. Every file your app creates gets .completeUntilFirstUserAuthentication unless you say otherwise. Apple's security guide puts it plainly: this is "the default class for all third-party app data not otherwise assigned to a Data Protection class". So your files really are encrypted at rest. They are also readable for the whole time the phone has been running since its last reboot, which for most people is weeks.

The second is that upgrading one sensitive file costs one line at write time. And it buys you exactly the WhenUnlocked trade-off: background code can no longer read that file while the phone is locked.

// 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 its statement PDFs deserve that line. A re-downloadable image cache does not. And notice that the trap is the same one as before, moved from the Keychain onto the file system. A .complete file that a background task needs will fail overnight for exactly the reason step 4 above describes.

The second dimension — should this exist on another device? — has a file-system name too: backups. A backup is the copy of the device that iCloud or a Mac keeps so the user can restore onto a new phone. Everything your app writes into Documents and Application Support goes into it. /tmp and /Library/Caches do not, because the system purges those directories anyway, so backups skip them by default.

Keeping any other file out is an explicit act, and it has a name: backup exclusion.

var resourceValues = URLResourceValues()
resourceValues.isExcludedFromBackup = true
try url.setResourceValues(resourceValues)     // url must be a var

Read Apple's own limits on that flag before you lean on it. It "exists only to provide guidance to the system about which files and directories it can exclude; it's not a mechanism to guarantee those items never appear in a backup or on a restored device." Ordinary file operations can reset it to false, so set it again every time you save the file.

So treat backup exclusion as a way to keep bulk out of backups, not as a security boundary. If a file must never leave this device, do not rely on the flag. Encrypt its contents with a key you keep in the Keychain under a ThisDeviceOnly constant, so a restored copy on another phone is unreadable.

Underneath all of this sits one compliance question, and it is the question a privacy review will ask: can you name every place a copy of this data can end up?

Biometrics: two different jobs

Biometrics means authenticating someone by measuring their body instead of by something they remember. On Apple devices that is Face ID (the front sensor projects a dot pattern and builds a depth map of the face) or Touch ID (a sensor reads a fingerprint).

Your app never sees the face or the finger. The sensor data goes straight to the Secure Enclave, a separate processor built into the same chip, with its own memory and its own keys. The Secure Enclave does the comparison itself and tells the rest of the system yes or no. Even a fully compromised app cannot read the enrolled biometric data.

Face ID and Touch ID answer two different questions, and confusing them is a common design error.

Question one: is an authorised person holding the phone right now?

LAContext is the class that asks. It lives in the LocalAuthentication framework. You create one, call evaluatePolicy, iOS shows the Face ID or Touch ID prompt, and you get a boolean back. That is the right tool for gating a screen: reveal the balance, open the vault tab, show the card number.

Its limit is that it is an if-statement and nothing more. Nothing was encrypted and nothing was decrypted. A compromised process can jump over the check, because no data ever depended on the answer.

Question two: may this specific secret be decrypted at all?

SecAccessControl answers that one. It is an object you attach to a Keychain item when you create it, describing the conditions under which iOS will hand the item back. You build it with SecAccessControlCreateWithFlags, passing an accessibility constant plus one or more flags. Now the decryption itself is conditional. There is no boolean for an attacker to skip, because there is nothing to skip. Without a successful authentication the bytes stay encrypted.

Three flags matter, and the differences between them are exactly the detail interviews probe:

  • .userPresence — Apple's own description is "Constraint to access an item with either biometry or passcode". Read that carefully: this flag does not require biometrics. If Face ID fails, or was never enrolled, the device passcode unlocks the item instead. Apple also notes the item stays accessible "even if fingers are added or removed, or by Face ID if the user is re-enrolled".
  • .biometryAny — biometrics required, no passcode fallback. Adding a finger or re-enrolling a face does not invalidate the item.
  • .biometryCurrentSet — the strictest. Only the fingerprints or the face enrolled right now work. Apple: "The item is invalidated if fingers are added or removed for Touch ID, or if the user re-enrolls for Face ID."

.biometryCurrentSet exists for one threat in particular. Somebody has your unlocked phone for two minutes, knows your passcode, and adds their own fingerprint. Under .biometryAny they can now read the item. Under .biometryCurrentSet the item was destroyed the moment the new finger was enrolled.

Note the phrase user presence, which is where the flag name comes from. It means "a human is here and proved it", by face, by finger, or by passcode. It does not mean "a specific human's biometrics matched". If your requirement is the second one, .userPresence is the wrong flag.

The rule to remember: **gate screens with LAContext; protect secrets with a SecAccessControl on the Keychain item.** D1-05 uses the same split for Ledger's large-payment confirmation: the UI prompt is the first layer, and access control on the signing key is the second one underneath it.

The network side: ATS and pinning

App Transport Security, always shortened to ATS, is a rule iOS enforces on your app's network connections. In plain terms: URLSession refuses plaintext HTTP, and it refuses TLS that is too old or too weak. TLS is the encryption layer underneath https; it is what stops anyone sitting between your app and your server from reading the traffic. ATS is on by default for every app.

Picture what it is refusing. Over plain HTTP everything travels as readable text. Anyone else on the same café Wi-Fi can watch your user's token go past, copy it, and replay it as that user. Interception is the actual threat. App Review's questions about it are only the symptom you meet first.

The interview point is posture. NSAllowsArbitraryLoads in your Info.plist switches ATS off for every domain at once, and Apple states plainly that you "must supply a justification during App Store review" if you set it. The senior answer is that exceptions are per-domain, written down with a reason, and removed once the server is fixed. Not a first-week "make the warning go away" commit that outlives everyone who understood it.

Certificate pinning is the next step up, and the vocabulary matters here. When your app connects over https, the server presents a certificate: a document signed by a certificate authority, or CA — one of the organisations the device already trusts. Normally your app accepts any valid certificate a trusted CA issued for your domain. Pinning narrows that: your app accepts a connection only if the certificate chain contains one specific public key that you chose in advance. A rogue or compromised CA can still issue a certificate for your domain, but your app will not accept it.

What you pin is the SPKI hash: a SHA-256 digest of the certificate's Subject Public Key Info, which is the public key rather than the whole document. That distinction is the point. Certificates expire and get reissued routinely, while the key underneath usually stays the same, so pinning the key survives renewals that would break a pin on the certificate. On Apple platforms you can configure this declaratively with the NSPinnedDomains key in Info.plist, and Apple supports listing several keys for one domain precisely so you can carry a spare.

The senior answer to "do you pin?" is a trade-off, not a yes. Here is the failure the trade-off is about. Follow the sequence:

  1. Your app ships with one pin: the hash of your server's current public key.
  2. A year later, ops rotates the server's key. Perhaps the old one was suspected compromised, perhaps it was routine.
  3. Every copy of your app already installed on a phone compares the new key against the old pin. They do not match.
  4. Every request fails during the TLS handshake, before any HTTP is spoken. You cannot even show a helpful message fetched from your server, because the app cannot reach your server.
  5. The only fix is a new build, through App Review, that every user has to install. Until they do, the app does nothing.

That is why "ship a backup pin" is not a nicety. You pin two keys: the one in use, and its successor, generated and pinned before anyone needs it. Rotation then means switching the server to a key the installed apps already trust.

So: high-sensitivity domains — banking, health, payments — usually yes, with a backup pin and a written rotation runbook. A content app, often no, because the operational risk of bricking every install outweighs a threat that ATS already covers most of. Saying both halves is what passes.

One practical consequence catches every team that ships pinning: it breaks your own debugging proxy too. Proxyman and Charles read HTTPS by presenting their own certificate and decrypting in the middle, which is precisely the attack pinning refuses — so your requests start failing at the TLS handshake with what looks like a network outage. Compile the pinning out of debug builds with a build-configuration flag, never a runtime toggle: a switch that turns pinning off at runtime is exactly the thing an attacker would look for in your shipped binary. D2-09 covers the debugging side of this.

And the client-secret rule once more, because interviews circle back to it. Anything inside your app binary is public. API keys are readable straight out of the shipped app, obfuscated or not, and obfuscation only costs the attacker an afternoon. The pattern that works: the client authenticates users and holds nothing but their tokens. The server holds the service secrets and decides what each user is allowed to do.

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?

Loading exercise…
Loading exercise…
RecallSaved on this device. Never graded.

Close the page. Write out the full storage-and-security answer for a banking-style app, in the words you would use in an interview. Cover five things:

  1. Where the session token and the refresh token each live, and which accessibility constant each one gets.
  2. Which Data Protection class the cached statements get, and why.
  3. What biometrics actually protect, and by which mechanism.
  4. The pinning decision, with its rotation caveat.
  5. The one thing that must never ship inside the binary.

Then check yourself against the lesson.

Checkpoint

You can now:

  • Say where every kind of app data lives, with a reason for each, including what happens on reinstall
  • Choose a Keychain accessibility constant from two questions: when your code has to read the item, and whether it should ever exist on another device
  • Pick a Data Protection class for a file, and say what backup exclusion does and does not guarantee
  • Separate gating a screen with LAContext from protecting a secret with SecAccessControl, and name what each biometric flag really requires
  • Argue ATS posture and certificate pinning as trade-offs, with the operational cost stated

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.

</content>