macOS integration
Store a secret in Keychain
Keep tokens and passwords out of UserDefaults and files by using a narrowly named Keychain item.
12 minute lesson
Say the notes app grows a sync feature and receives an API token. Where does it go? Not UserDefaults. Everything there sits in a plain plist on disk, readable with one command:
defaults read com.flaviocopes.Notes
Any process running as the user can do that. The Keychain is the answer macOS provides: an encrypted store, unlocked with the user’s login, with access controlled per app.
The Keychain API is the Security framework — C functions driven by dictionaries. Do not spread these calls around your codebase. Wrap them once behind a small interface:
protocol SecretStore {
func save(_ value: Data, account: String) throws
func load(account: String) throws -> Data?
func delete(account: String) throws
}
Here is the save, storing a generic password item identified by a service and an account:
func save(_ value: Data, account: String) throws {
let query: [String: Any] = [
kSecClass as String: kSecClassGenericPassword,
kSecAttrService as String: "com.flaviocopes.Notes",
kSecAttrAccount as String: account,
kSecValueData as String: value,
]
SecItemDelete(query as CFDictionary)
let status = SecItemAdd(query as CFDictionary, nil)
guard status == errSecSuccess else {
throw KeychainError.unexpectedStatus(status)
}
}
The service and account names are the item’s identity. Name them precisely — your bundle identifier for the service, "sync-token" for the account — so a Keychain search never matches more than you intended.
Loading mirrors it:
func load(account: String) throws -> Data? {
let query: [String: Any] = [
kSecClass as String: kSecClassGenericPassword,
kSecAttrService as String: "com.flaviocopes.Notes",
kSecAttrAccount as String: account,
kSecReturnData as String: true,
]
var result: AnyObject?
let status = SecItemCopyMatching(query as CFDictionary, &result)
if status == errSecItemNotFound { return nil }
guard status == errSecSuccess else {
throw KeychainError.unexpectedStatus(status)
}
return result as? Data
}
errSecItemNotFound is a normal answer — the user has not signed in yet — so it becomes nil rather than a thrown error. Every other non-success status is worth surfacing, mapped to an error type. Never print the secret itself while debugging, not even temporarily. Logs outlive debugging sessions.
The protocol earns its keep in tests. Give the test target an in-memory implementation backed by a dictionary, and your sign-in logic can be tested for success, missing token, and Keychain failure without touching the real Keychain.
Verify with a round trip: save a token, quit, relaunch, load it back. You can also inspect the item in the Keychain Access app — search for your service name. And remember the boundary: the Keychain protects the token at rest. Once your code loads it, where it travels — logs, error reports, URLs — is entirely your responsibility.
Lesson completed