Using the Keychain directly, without a wrapper library
Every iOS project I have joined has had a Keychain wrapper — either a dependency or a copied file — and in each case the wrapper was larger than the thing it wrapped.
The Keychain API is genuinely unpleasant: C functions, CFDictionary, untyped Any values, and
error codes instead of errors. It is also small. Here is the whole of what most apps need.
The four operations
import Security
enum Keychain {
enum Error: Swift.Error {
case unexpectedStatus(OSStatus)
}
static func set(_ data: Data, for key: String) throws {
let query: [String: Any] = [
kSecClass as String: kSecClassGenericPassword,
kSecAttrAccount as String: key,
]
let attributes: [String: Any] = [
kSecValueData as String: data,
kSecAttrAccessible as String: kSecAttrAccessibleAfterFirstUnlock,
]
let status = SecItemUpdate(query as CFDictionary, attributes as CFDictionary)
switch status {
case errSecSuccess:
return
case errSecItemNotFound:
var newItem = query
newItem.merge(attributes) { current, _ in current }
let addStatus = SecItemAdd(newItem as CFDictionary, nil)
guard addStatus == errSecSuccess else { throw Error.unexpectedStatus(addStatus) }
default:
throw Error.unexpectedStatus(status)
}
}
static func get(_ key: String) throws -> Data? {
let query: [String: Any] = [
kSecClass as String: kSecClassGenericPassword,
kSecAttrAccount as String: key,
kSecReturnData as String: true,
kSecMatchLimit as String: kSecMatchLimitOne,
]
var result: AnyObject?
let status = SecItemCopyMatching(query as CFDictionary, &result)
switch status {
case errSecSuccess:
return result as? Data
case errSecItemNotFound:
return nil
default:
throw Error.unexpectedStatus(status)
}
}
static func delete(_ key: String) throws {
let query: [String: Any] = [
kSecClass as String: kSecClassGenericPassword,
kSecAttrAccount as String: key,
]
let status = SecItemDelete(query as CFDictionary)
guard status == errSecSuccess || status == errSecItemNotFound else {
throw Error.unexpectedStatus(status)
}
}
}
Sixty lines, no dependency, and you can read all of it.
The three details that matter
SecItemAdd fails on a duplicate. It returns errSecDuplicateItem rather than overwriting,
which is why set above tries SecItemUpdate first and falls back to adding. Wrappers that call
delete then add have a window where the value does not exist, which matters if two threads race.
The accessibility constant is a security decision, not a default. This is the one people get wrong:
| Constant | Available |
|---|---|
kSecAttrAccessibleWhenUnlocked |
Only while the device is unlocked |
kSecAttrAccessibleAfterFirstUnlock |
After the first unlock since boot — needed for background work |
…ThisDeviceOnly variants |
Same, but excluded from backups |
The default if you omit it is kSecAttrAccessibleWhenUnlocked, which means a background refresh at
3am cannot read your auth token. That is the cause of “the app logs me out overnight” reports.
AfterFirstUnlock is the right answer for a token that background code needs.
WhenUnlockedThisDeviceOnly is right for something that must never leave the device, at the cost of
the user losing it when they restore to a new phone.
Warning
Keychain items survive app deletion. Uninstall the app, reinstall it, and the old token is
still there. That is occasionally what you want and usually a surprise — it is why testers report
being “still logged in” after a fresh install. If you want a clean state on install, clear the
Keychain on first launch using a flag in UserDefaults, which does not survive.
Making it typed
Two extensions cover almost every use:
extension Keychain {
static func setString(_ string: String, for key: String) throws {
try set(Data(string.utf8), for: key)
}
static func string(_ key: String) throws -> String? {
try get(key).flatMap { String(data: $0, encoding: .utf8) }
}
static func setCodable<T: Encodable>(_ value: T, for key: String) throws {
try set(JSONEncoder().encode(value), for: key)
}
static func codable<T: Decodable>(_ type: T.Type, for key: String) throws -> T? {
try get(key).map { try JSONDecoder().decode(T.self, from: $0) }
}
}
That is the entire API surface of most wrapper libraries.
What a wrapper is actually for
I do not want to argue that dependencies are always wrong. A wrapper earns its place if you need:
- Access groups, for sharing between an app and its extensions
- iCloud Keychain sync (
kSecAttrSynchronizable) - Biometric protection via
SecAccessControlandLAContext - Certificates and keys, not just passwords
Those are genuinely fiddly and a well-tested library is worth it. But if what you need is “store a
token, read a token, delete a token”, the code above is all of it — and the sixty lines you own are
easier to debug than a dependency, because when a Keychain call fails the useful information is the
OSStatus, and most wrappers throw it away.