macOS integration
Store a secret in Keychain
Keep tokens and passwords out of UserDefaults and files by using a narrowly named Keychain item.
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. It’s an encrypted store, unlocked with the user’s login, with access controlled per app.
Wrap the API once
The Keychain API is the Security framework: C functions driven by dictionaries. Don’t 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
}
Save a generic password
Here’s the save. It stores 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 SecItemDelete call first removes any existing item with the same identity, so saving twice replaces instead of failing with a duplicate error.
The service and account names are the item’s identity. Name them precisely. Your bundle identifier for the service, "sync-token" for the account. That way a Keychain search never matches more than you intended.
Load it back
Loading mirrors the save:
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 hasn’t 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 pays off in tests
Give the test target an in-memory implementation backed by a dictionary. Now your sign-in logic can be tested for success, missing token, and Keychain failure without touching the real Keychain.
Verify
Do a round trip: save a token, quit, relaunch, load it back. You can also inspect the item in the Keychain Access app by searching for your service name.
And remember the boundary. The Keychain protects the token at rest. Once your code loads it, where it travels is entirely your responsibility. Logs, error reports, URLs. Keep it out of all three.
Lesson completed