Setting up Keychain for Secure Data Storage in iOS
After app update, the authorization token is not found — user sees login screen again. Or worse: the token is saved, but accessible to another app from the same vendor without restrictions. Both situations result from improperly configured Keychain.
Where Exactly It Breaks
Most common mistake: storing tokens via UserDefaults. Data goes into Library/Preferences/*.plist, which is included in iCloud backup and accessible via iTunes backup extraction tools like iBackup Viewer. This is not theoretical.
Second most common: using Keychain without explicit kSecAttrAccessible. Default value is kSecAttrAccessibleWhenUnlocked, which is reasonable, but many projects don't consider whether data needs access when screen is locked (for background tasks) or only when device is unlocked and only after first unlock after reboot. These are fundamentally different threat models.
Third: missing kSecAttrAccessGroup when working in App Group. If you have main app and widget or extension, and token is not shared explicitly, extension won't see Keychain entry, even though Bundle ID is from the same group.
Proper Configuration via Security Framework
Basic write pattern:
let query: [String: Any] = [
kSecClass as String: kSecClassGenericPassword,
kSecAttrService as String: "com.example.app.auth",
kSecAttrAccount as String: "access_token",
kSecAttrAccessible as String: kSecAttrAccessibleAfterFirstUnlockThisDeviceOnly,
kSecValueData as String: tokenData,
kSecAttrAccessGroup as String: "TEAMID.com.example.shared" // if App Group needed
]
kSecAttrAccessibleAfterFirstUnlockThisDeviceOnly is the right choice for most tokens: accessible after first unlock post-reboot, not synced to iCloud, not transferred to other devices. If you need synchronization between user's devices (e.g., notes password), then kSecAttrAccessibleWhenUnlocked with kSecAttrSynchronizable: kCFBooleanTrue. But for auth tokens, iCloud Keychain sync is undesirable — compromise of one device compromises all.
For read with update (upsert), first try SecItemUpdate, on errSecItemNotFound — SecItemAdd. Don't do SecItemDelete + SecItemAdd — creates race condition in multithreaded environment.
Biometric Protection via LocalAuthentication
If data needs biometric protection before access (encryption keys, private keys):
let access = SecAccessControlCreateWithFlags(
nil,
kSecAttrAccessibleWhenPasscodeSetThisDeviceOnly,
.biometryCurrentSet, // .userPresence for passcode fallback
nil
)!
Flag .biometryCurrentSet invalidates the record when biometry changes (new fingerprint, switch to Face ID) — intentional behavior for high-secret data. For less critical .biometryAny preserves access after new fingerprints are added.
Wrapper and Testability
Direct SecItemAdd calls in production code make unit-tests impossible without real Keychain. Wrap in protocol:
protocol KeychainService {
func save(_ data: Data, for key: String) throws
func load(for key: String) throws -> Data
func delete(for key: String) throws
}
In tests, substitute InMemoryKeychainService. In production — implementation via Security framework. Standard approach in Clean Architecture for iOS.
Process
Audit current code — find UserDefaults, NSKeyedArchiver, plaintext in files. Next, design KeychainService for specific stored data set, implement with correct kSecAttrAccessible, configure App Group if extension sharing needed, cover with unit-tests via mock. Separately — test behavior on biometry change and app update.
Timeline: 1–3 days. Simple UserDefaults to Keychain swap for one data type — closer to one day. Full audit + refactor + App Group + biometry — up to three.







