Storing tokens in the Keychain: the settings that matter
SecItemAdd is the easy part. Accessibility classes, device-only items and the item that outlives your app decide whether your login really is secure.
Every app with a login stores a token somewhere, and every iOS developer knows the answer is "the Keychain, not UserDefaults". The Keychain API is old C-style code with dictionaries of CFString keys, so most teams wrap it once and never look at it again. The wrapper is usually fine. The attributes it passes are where the decisions are.
A minimal save
import Security
enum KeychainError: Error { case status(OSStatus) }
func saveToken(_ token: Data, account: String) throws {
let query: [String: Any] = [
kSecClass as String: kSecClassGenericPassword,
kSecAttrService as String: "com.example.app.auth",
kSecAttrAccount as String: account,
]
let attributes: [String: Any] = [
kSecValueData as String: token,
kSecAttrAccessible as String: kSecAttrAccessibleAfterFirstUnlockThisDeviceOnly,
]
var status = SecItemUpdate(query as CFDictionary, attributes as CFDictionary)
if status == errSecItemNotFound {
status = SecItemAdd(query.merging(attributes) { $1 } as CFDictionary, nil)
}
guard status == errSecSuccess else { throw KeychainError.status(status) }
}Update first, add if missing. Calling SecItemAdd for an item that already exists fails with errSecDuplicateItem, which is the most common Keychain bug in code review.
Accessibility: when can the item be read?
kSecAttrAccessible decides when the system will hand the item to your app. The two you will use most:
| Value | Readable | Typical use |
|---|---|---|
WhenUnlocked | Only while the device is unlocked | Data only needed while the user is in the app |
AfterFirstUnlock | After the first unlock since boot, also in the background | Tokens used by background refresh, push handling, widgets |
If your app refreshes data in the background, WhenUnlocked will fail there with errSecInteractionNotAllowed whenever the phone is locked. The failure is silent in testing, because test devices are usually unlocked.
ThisDeviceOnly: should the token move to a new phone?
Each accessibility value has a ThisDeviceOnly variant. Items with it are not included in encrypted backups restored to another device, and are not migrated to a new phone. For a session token that is usually what you want: a restored phone should log in again rather than inherit a session issued to different hardware.
The item that survives deletion
Keychain items are not removed when the user deletes the app. Reinstall, and the old token is still there, possibly for a user who sold the phone, or for a backend environment that no longer exists.
A common pattern is to clear the app's Keychain items on the first launch after install, using a flag in UserDefaults (which is removed with the app):
let key = "hasLaunchedBefore"
if !UserDefaults.standard.bool(forKey: key) {
try? clearKeychain(service: "com.example.app.auth")
UserDefaults.standard.set(true, forKey: key)
}Requiring Face ID
For high-value actions you can tie an item to biometrics with SecAccessControlCreateWithFlags and the .biometryCurrentSet flag. The item is then invalidated when the enrolled fingers or faces change, which is exactly the property you want for a payment confirmation, and exactly the property that surprises users if it guards their whole login. Use it for the action, not for the session.
Checklist
- Update, then add: never rely on
SecItemAddalone. - Pick
AfterFirstUnlockif anything reads the item in the background. - Prefer
ThisDeviceOnlyfor session tokens. - Clear stale items on the first launch after install.
- Keep biometric access control for specific actions.