Introduction to macOS Keychain Security
The macOS Keychain is a secure, encrypted storage system built into Apple's operating systems that allows applications to store sensitive data such as passwords, cryptographic keys, certificates, and secure notes. As a developer, understanding how to properly leverage the Keychain is essential for building secure applications that protect user credentials and other confidential information. This tutorial will walk you through the fundamentals of Keychain security, practical implementation patterns, and best practices to follow when integrating Keychain services into your macOS applications.
What is the macOS Keychain?
The Keychain is a password management system provided by Apple that stores sensitive data in an encrypted database. On macOS, the Keychain is implemented as a set of files stored on disk, encrypted using AES-256 encryption. The system provides a C-based API (Security framework) and higher-level Swift/Objective-C APIs that developers can use to store and retrieve secrets securely.
There are several types of keychains available on macOS:
- User Login Keychain: Automatically unlocked when the user logs in; the default storage location for most application secrets.
- System Keychain: Shared across all users on the system; used for system-wide credentials like Wi-Fi passwords.
- iCloud Keychain: Syncs keychain items across the user's Apple devices, encrypted end-to-end.
- Custom Keychains: Developer-created keychain files with custom passwords, useful for specialized security requirements.
Each keychain item is identified by a set of attributes, including a service name, account name, and optional metadata. Items can be protected with different access controls, requiring authentication before retrieval or modification.
Why Keychain Security Matters
Storing sensitive data securely is one of the most critical responsibilities of any application developer. Hardcoding secrets in source code, storing passwords in plain text files, or using UserDefaults for credentials are all common mistakes that lead to security vulnerabilities. The Keychain addresses these concerns by providing:
- Encryption at rest: All keychain data is encrypted using strong cryptographic algorithms, protecting it even if an attacker gains physical access to the storage.
- Access control: Fine-grained permissions control which applications and processes can access specific keychain items.
- User authentication: Items can require the user to enter their login password, use Touch ID, or provide a custom password before access is granted.
- Sandboxing integration: In sandboxed macOS apps, keychain items are isolated per application, preventing unauthorized cross-application access.
- Audit and monitoring: The system tracks access to keychain items, and users can inspect keychain contents through the Keychain Access utility.
Failing to use the Keychain properly can result in credential theft, unauthorized access to backend services, and compromise of user accounts. For applications handling authentication tokens, API keys, encryption keys, or any other secrets, the Keychain should be the default storage mechanism.
Understanding Keychain Item Classes
Before writing code, it is important to understand the different classes of keychain items. Each class corresponds to a type of secret and carries different default attributes. The primary item classes include:
kSecClassGenericPassword— General-purpose passwords not tied to a specific service like a website or network server.kSecClassInternetPassword— Passwords associated with internet resources, including attributes like server, protocol, and path.kSecClassCertificate— Digital certificates used for identity verification and encryption.kSecClassKey— Cryptographic keys used for encryption, decryption, and signing operations.kSecClassIdentity— A combination of a certificate and its associated private key.
For most application use cases, kSecClassGenericPassword is the appropriate class. It allows you to store arbitrary secret data associated with a service and account identifier.
Setting Up Your Project
To use Keychain services in a macOS application, you need to link against the Security framework. In Xcode, navigate to your target's settings, go to the "General" tab, and add Security.framework under "Frameworks and Libraries". If you are using Swift Package Manager or another build system, ensure the framework is linked appropriately.
For sandboxed applications, you also need to enable the App Sandbox capability and add the keychain-access-groups entitlement if you plan to share keychain items across multiple applications from the same developer team.
Storing a Password in the Keychain
The most common Keychain operation is storing a generic password. The Security framework uses a dictionary-based API where you specify attributes and values. Here is a Swift implementation of a function that saves a password to the Keychain:
import Foundation
import Security
func savePassword(service: String, account: String, password: String) -> OSStatus {
let passwordData = password.data(using: .utf8)!
let query: [String: Any] = [
kSecClass as String: kSecClassGenericPassword,
kSecAttrService as String: service,
kSecAttrAccount as String: account,
kSecValueData as String: passwordData
]
// Delete any existing item first to avoid duplicates
SecItemDelete(query as CFDictionary)
// Add the new item
let status = SecItemAdd(query as CFDictionary, nil)
return status
}
// Usage example
let status = savePassword(
service: "com.mycompany.myapp",
account: "user@example.com",
password: "mySecurePassword123"
)
if status == errSecSuccess {
print("Password saved successfully")
} else {
print("Failed to save password with status: \(status)")
}
Notice that we first attempt to delete any existing item with the same service and account before adding the new one. This prevents the errSecDuplicateItem error that occurs when you try to add an item that already exists. Alternatively, you could use SecItemUpdate to modify an existing item in place.
Retrieving a Password from the Keychain
To read a stored password, you construct a query dictionary specifying the attributes that identify the item and request the data back. Here is a function that retrieves a password:
func loadPassword(service: String, account: String) -> String? {
let query: [String: Any] = [
kSecClass as String: kSecClassGenericPassword,
kSecAttrService as String: service,
kSecAttrAccount as String: account,
kSecMatchLimit as String: kSecMatchLimitOne,
kSecReturnData as String: true
]
var item: CFTypeRef?
let status = SecItemCopyMatching(query as CFDictionary, &item)
guard status == errSecSuccess else {
print("Failed to load password with status: \(status)")
return nil
}
guard let passwordData = item as? Data,
let password = String(data: passwordData, encoding: .utf8) else {
return nil
}
return password
}
// Usage example
if let password = loadPassword(service: "com.mycompany.myapp", account: "user@example.com") {
print("Retrieved password: \(password)")
} else {
print("No password found")
}
The kSecMatchLimitOne attribute ensures that only a single result is returned, and kSecReturnData specifies that the actual secret data should be included in the result. Without kSecReturnData, you would only receive the item's attributes.
Updating an Existing Keychain Item
When a user changes their password, you need to update the existing keychain item rather than deleting and recreating it. The SecItemUpdate function handles this:
func updatePassword(service: String, account: String, newPassword: String) -> OSStatus {
let query: [String: Any] = [
kSecClass as String: kSecClassGenericPassword,
kSecAttrService as String: service,
kSecAttrAccount as String: account
]
let attributesToUpdate: [String: Any] = [
kSecValueData as String: newPassword.data(using: .utf8)!
]
let status = SecItemUpdate(query as CFDictionary, attributesToUpdate as CFDictionary)
return status
}
// Usage example
let updateStatus = updatePassword(
service: "com.mycompany.myapp",
account: "user@example.com",
newPassword: "newSecurePassword456"
)
if updateStatus == errSecSuccess {
print("Password updated successfully")
} else if updateStatus == errSecItemNotFound {
print("Item not found, consider creating it instead")
} else {
print("Update failed with status: \(updateStatus)")
}
Deleting a Keychain Item
When a user logs out or deletes their account, you should remove their credentials from the Keychain. The SecItemDelete function removes items matching the specified query:
func deletePassword(service: String, account: String) -> OSStatus {
let query: [String: Any] = [
kSecClass as String: kSecClassGenericPassword,
kSecAttrService as String: service,
kSecAttrAccount as String: account
]
let status = SecItemDelete(query as CFDictionary)
return status
}
// Usage example
let deleteStatus = deletePassword(service: "com.mycompany.myapp", account: "user@example.com")
if deleteStatus == errSecSuccess {
print("Password deleted successfully")
} else if deleteStatus == errSecItemNotFound {
print("Item was not found in keychain")
} else {
print("Delete failed with status: \(deleteStatus)")
}
Adding Access Control with Touch ID and Password Prompts
One of the most powerful features of the Keychain is the ability to require biometric authentication or a password prompt before an item can be accessed. This is done using SecAccessControl objects. The following example creates a keychain item that requires Touch ID (or the device password as a fallback) to retrieve:
func savePasswordWithAccessControl(service: String, account: String, password: String) -> OSStatus {
var error: Unmanaged<CFError>?
guard let accessControl = SecAccessControlCreateWithFlags(
nil,
kSecAttrAccessibleWhenUnlockedThisDeviceOnly,
.userPresence,
&error
) else {
print("Failed to create access control: \(error?.takeRetainedValue().localizedDescription ?? "unknown error")")
return errSecParam
}
let passwordData = password.data(using: .utf8)!
let query: [String: Any] = [
kSecClass as String: kSecClassGenericPassword,
kSecAttrService as String: service,
kSecAttrAccount as String: account,
kSecValueData as String: passwordData,
kSecAttrAccessControl as String: accessControl
]
SecItemDelete(query as CFDictionary)
let status = SecItemAdd(query as CFDictionary, nil)
return status
}
// Usage example
let protectedStatus = savePasswordWithAccessControl(
service: "com.mycompany.myapp.secure",
account: "user@example.com",
password: "biometricProtectedPassword"
)
if protectedStatus == errSecSuccess {
print("Password saved with biometric protection")
} else {
print("Failed with status: \(protectedStatus)")
}
The kSecAttrAccessibleWhenUnlockedThisDeviceOnly accessibility attribute means the item can only be accessed when the device is unlocked and will not be synced to other devices via iCloud Keychain backups. The .userPresence flag requires either Touch ID, Face ID (on supported hardware), or the device password to access the item.
Available accessibility levels include:
kSecAttrAccessibleWhenUnlocked— Item is accessible when the device is unlocked; can be backed up.kSecAttrAccessibleWhenUnlockedThisDeviceOnly— Same as above but not included in backups.kSecAttrAccessibleAfterFirstUnlock— Accessible after the first unlock since boot; suitable for background tasks.kSecAttrAccessibleAfterFirstUnlockThisDeviceOnly— Same as above but not backed up.kSecAttrAccessibleWhenPasscodeSetThisDeviceOnly— Only available if a passcode is set; removed if passcode is removed.
Building a Reusable Keychain Wrapper
For production applications, it is best to encapsulate Keychain operations behind a clean API. The following Swift class provides a reusable wrapper with error handling and type safety:
import Foundation
import Security
enum KeychainError: Error {
case unhandledError(status: OSStatus)
case itemNotFound
case invalidData
case encodingFailed
}
class KeychainManager {
private let service: String
init(service: String) {
self.service = service
}
func save(account: String, data: Data) throws {
let query: [String: Any] = [
kSecClass as String: kSecClassGenericPassword,
kSecAttrService as String: service,
kSecAttrAccount as String: account
]
let attributes: [String: Any] = [
kSecValueData as String: data
]
let status = SecItemUpdate(query as CFDictionary, attributes as CFDictionary)
if status == errSecItemNotFound {
var newItem = query
newItem[kSecValueData as String] = data
let addStatus = SecItemAdd(newItem as CFDictionary, nil)
if addStatus != errSecSuccess {
throw KeychainError.unhandledError(status: addStatus)
}
} else if status != errSecSuccess {
throw KeychainError.unhandledError(status: status)
}
}
func save(account: String, string: String) throws {
guard let data = string.data(using: .utf8) else {
throw KeychainError.encodingFailed
}
try save(account: account, data: data)
}
func load(account: String) throws -> Data {
let query: [String: Any] = [
kSecClass as String: kSecClassGenericPassword,
kSecAttrService as String: service,
kSecAttrAccount as String: account,
kSecMatchLimit as String: kSecMatchLimitOne,
kSecReturnData as String: true
]
var result: CFTypeRef?
let status = SecItemCopyMatching(query as CFDictionary, &result)
switch status {
case errSecSuccess:
guard let data = result as? Data else {
throw KeychainError.invalidData
}
return data
case errSecItemNotFound:
throw KeychainError.itemNotFound
default:
throw KeychainError.unhandledError(status: status)
}
}
func loadString(account: String) throws -> String {
let data = try load(account: account)
guard let string = String(data: data, encoding: .utf8) else {
throw KeychainError.invalidData
}
return string
}
func delete(account: String) throws {
let query: [String: Any] = [
kSecClass as String: kSecClassGenericPassword,
kSecAttrService as String: service,
kSecAttrAccount as String: account
]
let status = SecItemDelete(query as CFDictionary)
if status != errSecSuccess && status != errSecItemNotFound {
throw KeychainError.unhandledError(status: status)
}
}
func deleteAll() throws {
let query: [String: Any] = [
kSecClass as String: kSecClassGenericPassword,
kSecAttrService as String: service
]
let status = SecItemDelete(query as CFDictionary)
if status != errSecSuccess && status != errSecItemNotFound {
throw KeychainError.unhandledError(status: status)
}
}
}
// Usage example
let keychain = KeychainManager(service: "com.mycompany.myapp")
do {
try keychain.save(account: "api_token", string: "abc123secrettoken")
let token = try keychain.loadString(account: "api_token")
print("API Token: \(token)")
try keychain.delete(account: "api_token")
print("Token removed")
} catch KeychainError.itemNotFound {
print("Item not found in keychain")
} catch {
print("Keychain error: \(error)")
}
Using the Command-Line Keychain Tools
macOS includes a command-line tool called security that allows you to interact with keychains from the terminal. This is useful for debugging, scripting, and testing. Here are some common commands:
# Add a generic password to the default keychain
security add-generic-password -a "user@example.com" -s "com.mycompany.myapp" -w "myPassword123"
# Retrieve a generic password (will prompt for keychain unlock if needed)
security find-generic-password -a "user@example.com" -s "com.mycompany.myapp" -w
# Delete a generic password
security delete-generic-password -a "user@example.com" -s "com.mycompany.myapp"
# List all keychains
security list-keychains
# Create a new custom keychain
security create-keychain -p "keychainPassword" ~/Documents/MyApp.keychain
# Add a keychain to the search list
security list-keychains -s ~/Documents/MyApp.keychain
# Lock and unlock a keychain
security lock-keychain ~/Documents/MyApp.keychain
security unlock-keychain -p "keychainPassword" ~/Documents/MyApp.keychain
# Show all generic password items (attributes only, not values)
security dump-keychain
These commands are invaluable during development for verifying that your application is storing items correctly and inspecting the keychain state.
Sharing Keychain Items Between Apps
If you are developing a suite of applications that need to share credentials, you can use keychain access groups. This requires that all participating apps belong to the same developer team and have the appropriate entitlements configured.
To enable keychain sharing, add the following entitlement to your app's entitlements file:
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>keychain-access-groups</key>
<array>
<string>$(AppIdentifierPrefix)com.mycompany.shared</string>
</array>
</dict>
</plist>
When storing an item in a shared group, include the access group attribute in your query:
let query: [String: Any] = [
kSecClass as String: kSecClassGenericPassword,
kSecAttrService as String: "com.mycompany.myapp",
kSecAttrAccount as String: "user@example.com",
kSecValueData as String: passwordData,
kSecAttrAccessGroup as String: "TEAMID.com.mycompany.shared"
]
Replace TEAMID with your actual Apple Developer Team ID. All apps with the same access group entitlement can read and write items in that group.
Storing Cryptographic Keys in the Keychain
Beyond passwords, the Keychain is an excellent place to store cryptographic keys. Storing keys in the Keychain ensures they never exist in plaintext outside of the secure storage. You can generate keys directly in the Keychain so they never leave the secure enclave-like protection:
import Foundation
import Security
func generateAndStoreRSAKey(tag: String) -> OSStatus {
let keyAttributes: [String: Any] = [
kSecAttrKeyType as String: kSecAttrKeyTypeRSA,
kSecAttrKeySizeInBits as String: 2048,
kSecPrivateKeyAttrs as String: [
kSecAttrIsPermanent as String: true,
kSecAttrApplicationTag as String: tag.data(using: .utf8)!,
kSecAttrAccessible as String: kSecAttrAccessibleWhenUnlockedThisDeviceOnly
]
]
var error: Unmanaged<CFError>?
guard let privateKey = SecKeyCreateRandomKey(keyAttributes as CFDictionary, &error) else {
print("Key generation failed: \(error?.takeRetainedValue().localizedDescription ?? "")")
return errSecParam
}
// The private key is now stored in the keychain
// You can retrieve the public key from the private key
guard let publicKey = SecKeyCopyPublicKey(privateKey) else {
return errSecParam
}
print("RSA key pair generated and stored with tag: \(tag)")
return errSecSuccess
}
func retrievePrivateKey(tag: String) -> SecKey? {
let query: [String: Any] = [
kSecClass as String: kSecClassKey,
kSecAttrKeyType as String: kSecAttrKeyTypeRSA,
kSecAttrApplicationTag as String: tag.data(using: .utf8)!,
kSecReturnRef as String: true
]
var result: CFTypeRef?
let status = SecItemCopyMatching(query as CFDictionary, &result)
guard status == errSecSuccess else {
print("Failed to retrieve key with status: \(status)")
return nil
}
return (result as! SecKey)
}
// Usage
generateAndStoreRSAKey(tag: "com.mycompany.myapp.signingkey")
if let privateKey = retrievePrivateKey(tag: "com.mycompany.myapp.signingkey") {
print("Retrieved private key from keychain")
// Use the key for signing or decryption operations
}
Best Practices for Keychain Security
To maximize the security of your application's keychain usage, follow these best practices:
Use Unique Service Identifiers
Always use a reverse-DNS style service identifier (e.g., com.company.app.service) to avoid collisions with other applications. This also makes it easier to manage and debug keychain items.
Choose the Right Accessibility Level
Select the most restrictive accessibility level that still meets your application's needs. If the secret is only needed while the user is actively using the app, use kSecAttrAccessibleWhenUnlockedThisDeviceOnly. For background tasks, kSecAttrAccessibleAfterFirstUnlockThisDeviceOnly may be appropriate. Avoid ThisDeviceOnly variants only if you explicitly need iCloud Keychain syncing.
Minimize What You Store
Only store what is absolutely necessary. If you can derive a value or obtain it from a server at runtime, do not store it in the keychain. Prefer short-lived tokens over long-lived credentials, and refresh tokens regularly.
Always Handle Errors Gracefully
Keychain operations can fail for many reasons: the keychain may be locked, the item may not exist, or access may be denied. Always check the returned OSStatus and provide appropriate fallback behavior or user-facing error messages.
Clear Credentials on Logout
When a user logs out of your application, delete all associated keychain items. Do not leave orphaned credentials that could be accessed later. Implement a deleteAll method and call it during the logout flow.
Use Access Control for Sensitive Items
For highly sensitive items like banking credentials or master encryption keys, use SecAccessControl with biometric authentication. This ensures that even if an attacker has access to an unlocked device, they cannot retrieve the secret without biometric verification.
Avoid Storing Data in UserDefaults
Never store passwords, tokens, or any secret in UserDefaults. UserDefaults stores data in plain text plist files that are trivially readable. The Keychain should always be used for any sensitive data.
Test with the Keychain Access Utility
During development, use the built-in Keychain Access app (located in Applications/Utilities/) to inspect stored items, verify attributes, and confirm that items are being created and deleted as expected. This helps catch bugs early in the development cycle.
Consider Data Protection on macOS
On macOS, ensure your application is properly sandboxed. Sandboxing provides an additional layer of protection by restricting which keychain items your app can access. Without sandboxing, a malicious application could potentially access items stored by other non-sandboxed applications.
Do Not Log Secrets
Never log keychain data to console, crash reporting services, or analytics platforms. Even in debug builds, use redacted placeholders when you need to confirm that a value was retrieved. Logging secrets is one of the most common causes of accidental credential exposure.
Common Pitfalls and How to Avoid Them
Developers new to the Keychain API often encounter a few recurring issues. Being aware of these pitfalls will save you significant debugging time:
- errSecDuplicateItem (-25299): You attempted to add an item that already exists. Always check for existing items first or delete before adding.
- errSecItemNotFound (-25300): The query did not match any items. Ensure your service and account attributes match exactly what was used during storage.
- errSecAuthFailed (-25293): The keychain is locked or access was denied. On macOS, this can happen if the user has not unlocked their login keychain.
- errSecParam (-50): One or more parameters in your query dictionary are invalid. Check that all keys and values are of the correct type.
- Missing kSecReturnData: If you forget to include
kSecReturnData: truein a load query, you will receive attributes but not the actual secret data. - Not converting strings to Data: Keychain values must be
Dataobjects. Forgetting to convert aStringtoDatausing UTF-8 encoding will cause storage failures.
Conclusion
The macOS Keychain is a robust and well-designed system for securely storing sensitive data in your applications. By understanding the different item classes, accessibility levels, and access control options, you can build applications that protect user credentials with the same level of security that Apple's own frameworks rely on. The key takeaways are to always use the Keychain instead of plaintext storage, choose appropriate accessibility and access control settings for each item, handle errors thoroughly, and follow best practices such as clearing credentials on logout and never logging secrets. By integrating the patterns and code examples from this tutorial into your projects, you will significantly reduce the risk of credential exposure and provide your users with a more secure experience. Security is an ongoing process, so continue to review your keychain usage as your application evolves and as Apple introduces new security features in future macOS releases.