iOS Keychain Services: A Comprehensive Guide
Master iOS Keychain Services with this comprehensive guide covering item classes, access control, sharing, migration, and common pitfalls in Swift.
The iOS Keychain is the cornerstone of secure data storage on Apple platforms. It provides encrypted storage backed by hardware security modules, integrates with biometric authentication, and persists across app reinstalls. Yet despite its importance, the Keychain API is one of the most misunderstood and misused APIs in the iOS ecosystem. Its C-based interface, cryptic error codes, and implicit behaviors catch even experienced developers off guard.
This article provides a comprehensive guide to Keychain Services, covering everything from basic CRUD operations to advanced features like access groups, synchronization, and biometric protection.
Keychain Item Classes
The Keychain organizes stored data into item classes, each designed for a specific type of secret. Choosing the correct item class ensures proper handling and interoperability with system features.
import Security
// Keychain item classes
enum KeychainItemClass {
case genericPassword // Arbitrary secrets (tokens, API keys)
case internetPassword // Website/server credentials
case certificate // X.509 certificates
case key // Cryptographic keys
case identity // Certificate + private key pair
var secClass: CFString {
switch self {
case .genericPassword: return kSecClassGenericPassword
case .internetPassword: return kSecClassInternetPassword
case .certificate: return kSecClassCertificate
case .key: return kSecClassKey
case .identity: return kSecClassIdentity
}
}
}For most app development, kSecClassGenericPassword is the right choice. It stores arbitrary data blobs (up to about 4KB efficiently) and is the most flexible item class. Use kSecClassInternetPassword when storing website credentials, as it integrates with Safari AutoFill and Password Manager.
CRUD Operations with a Type-Safe Wrapper
The raw Keychain API requires constructing and parsing CFDictionary objects, which is error-prone. A type-safe wrapper makes Keychain operations reliable and readable.
public final class KeychainService {
private let service: String
private let accessGroup: String?
public init(service: String = Bundle.main.bundleIdentifier ?? "com.klivvr.storage",
accessGroup: String? = nil) {
self.service = service
self.accessGroup = accessGroup
}
// MARK: - Create / Update
public func save(_ data: Data, forKey key: String, accessibility: CFString = kSecAttrAccessibleWhenUnlockedThisDeviceOnly) throws {
// First try to update existing item
let updateQuery = baseQuery(forKey: key)
let updateAttributes: [String: Any] = [
kSecValueData as String: data
]
var status = SecItemUpdate(updateQuery as CFDictionary, updateAttributes as CFDictionary)
if status == errSecItemNotFound {
// Item doesn't exist, add it
var addQuery = baseQuery(forKey: key)
addQuery[kSecValueData as String] = data
addQuery[kSecAttrAccessible as String] = accessibility
status = SecItemAdd(addQuery as CFDictionary, nil)
}
guard status == errSecSuccess else {
throw KeychainError(status: status)
}
}
// MARK: - Read
public func load(forKey key: String) throws -> Data? {
var query = baseQuery(forKey: key)
query[kSecReturnData as String] = true
query[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 KeychainError(status: status)
}
}
// MARK: - Delete
public func delete(forKey key: String) throws {
let query = baseQuery(forKey: key)
let status = SecItemDelete(query as CFDictionary)
guard status == errSecSuccess || status == errSecItemNotFound else {
throw KeychainError(status: status)
}
}
// MARK: - Delete All
public func deleteAll() throws {
let query: [String: Any] = [
kSecClass as String: kSecClassGenericPassword,
kSecAttrService as String: service
]
let status = SecItemDelete(query as CFDictionary)
guard status == errSecSuccess || status == errSecItemNotFound else {
throw KeychainError(status: status)
}
}
// MARK: - Query All Keys
public func allKeys() throws -> [String] {
let query: [String: Any] = [
kSecClass as String: kSecClassGenericPassword,
kSecAttrService as String: service,
kSecReturnAttributes as String: true,
kSecMatchLimit as String: kSecMatchLimitAll
]
var result: AnyObject?
let status = SecItemCopyMatching(query as CFDictionary, &result)
switch status {
case errSecSuccess:
guard let items = result as? [[String: Any]] else { return [] }
return items.compactMap { $0[kSecAttrAccount as String] as? String }
case errSecItemNotFound:
return []
default:
throw KeychainError(status: status)
}
}
// MARK: - Helpers
private func baseQuery(forKey key: String) -> [String: Any] {
var query: [String: Any] = [
kSecClass as String: kSecClassGenericPassword,
kSecAttrAccount as String: key,
kSecAttrService as String: service
]
if let accessGroup {
query[kSecAttrAccessGroup as String] = accessGroup
}
return query
}
}
// Structured error type for Keychain operations
public struct KeychainError: Error, LocalizedError {
public let status: OSStatus
public var errorDescription: String? {
switch status {
case errSecDuplicateItem: return "Item already exists in Keychain"
case errSecItemNotFound: return "Item not found in Keychain"
case errSecAuthFailed: return "Authentication failed"
case errSecUserCanceled: return "User canceled authentication"
case errSecInteractionNotAllowed: return "Interaction not allowed (device locked?)"
case errSecDecode: return "Unable to decode item data"
default: return "Keychain error: \(status)"
}
}
}Biometric Authentication
The Keychain integrates with Face ID and Touch ID through SecAccessControl. This allows you to require biometric authentication before a Keychain item can be read.
import LocalAuthentication
extension KeychainService {
// Save with biometric protection
public func saveBiometricProtected(
_ data: Data,
forKey key: String,
prompt: String = "Authenticate to access secure data"
) throws {
var error: Unmanaged<CFError>?
guard let accessControl = SecAccessControlCreateWithFlags(
nil,
kSecAttrAccessibleWhenUnlockedThisDeviceOnly,
[.biometryCurrentSet],
&error
) else {
throw KeychainError(status: errSecParam)
}
let context = LAContext()
context.localizedReason = prompt
var query = baseQuery(forKey: key)
query[kSecValueData as String] = data
query[kSecAttrAccessControl as String] = accessControl
query[kSecUseAuthenticationContext as String] = context
// Delete existing item first
SecItemDelete(baseQuery(forKey: key) as CFDictionary)
let status = SecItemAdd(query as CFDictionary, nil)
guard status == errSecSuccess else {
throw KeychainError(status: status)
}
}
// Read with biometric authentication
public func loadBiometricProtected(
forKey key: String,
prompt: String = "Authenticate to access secure data"
) throws -> Data? {
let context = LAContext()
context.localizedReason = prompt
var query = baseQuery(forKey: key)
query[kSecReturnData as String] = true
query[kSecMatchLimit as String] = kSecMatchLimitOne
query[kSecUseAuthenticationContext as String] = context
query[kSecUseOperationPrompt as String] = prompt
var result: AnyObject?
let status = SecItemCopyMatching(query as CFDictionary, &result)
switch status {
case errSecSuccess:
return result as? Data
case errSecItemNotFound:
return nil
case errSecUserCanceled:
throw KeychainError(status: status)
case errSecAuthFailed:
throw KeychainError(status: status)
default:
throw KeychainError(status: status)
}
}
}The .biometryCurrentSet flag ties the item to the current set of enrolled biometrics. If the user adds or removes a fingerprint or resets Face ID, the item becomes inaccessible. Use .biometryAny if you want the item to remain accessible after biometric changes.
Keychain Sharing Between Apps
Apps in the same developer team can share Keychain items through access groups. This enables single sign-on between your apps.
// Keychain sharing between apps in the same team
let sharedKeychain = KeychainService(
service: "com.klivvr.shared",
accessGroup: "TEAM_ID.com.klivvr.shared"
)
// Save shared credentials
try sharedKeychain.save(tokenData, forKey: "auth_token")
// Any app with the same access group entitlement can read this
let token = try sharedKeychain.load(forKey: "auth_token")To enable sharing, both apps must include the same Keychain access group in their entitlements file (Keychain Sharing capability in Xcode).
iCloud Keychain Synchronization
Items can be synchronized across the user's devices via iCloud Keychain by using the kSecAttrSynchronizable attribute.
extension KeychainService {
public func saveSynchronizable(_ data: Data, forKey key: String) throws {
var query = baseQuery(forKey: key)
query[kSecValueData as String] = data
query[kSecAttrSynchronizable as String] = true
query[kSecAttrAccessible as String] = kSecAttrAccessibleAfterFirstUnlock
SecItemDelete(query as CFDictionary)
let status = SecItemAdd(query as CFDictionary, nil)
guard status == errSecSuccess else {
throw KeychainError(status: status)
}
}
}
// Note: Synchronizable items CANNOT use:
// - kSecAttrAccessibleWhenUnlockedThisDeviceOnly
// - kSecAttrAccessibleAfterFirstUnlockThisDeviceOnly
// - Any "ThisDeviceOnly" accessibility level
// - SecAccessControl with biometry flagsUse iCloud synchronization judiciously. It is appropriate for credentials the user needs on all devices (like a shared authentication token) but not for device-specific secrets.
Common Pitfalls and Solutions
Keychain development is rife with subtle issues. Here are the most common pitfalls we have encountered.
// Pitfall 1: Forgetting to delete before adding (causes errSecDuplicateItem)
// WRONG:
func saveWrong(_ data: Data, forKey key: String) {
let query: [String: Any] = [/*...*/]
SecItemAdd(query as CFDictionary, nil) // Fails if key exists!
}
// RIGHT: Always try update first, then add
func saveRight(_ data: Data, forKey key: String) {
// Try update, fall back to add (as shown in KeychainService above)
}
// Pitfall 2: Querying with too-specific attributes
// Items saved with different accessibility levels are different items
// Searching without specifying accessibility returns the first match
// Pitfall 3: Background access failure
// Items with .whenUnlocked accessibility are inaccessible when the device is locked
// Background tasks (push notifications, background refresh) need .afterFirstUnlock
// Pitfall 4: Simulator vs device behavior
// The Simulator Keychain has different behavior:
// - No Secure Enclave support
// - Different access control enforcement
// - Items persist across app reinstalls differentlyPractical Tips
Always use the service attribute to namespace your Keychain items and avoid collisions with other apps or system items. Handle errSecInteractionNotAllowed gracefully -- it means the device is locked and the item's accessibility level does not permit background access. Test Keychain operations on real devices, not just the Simulator. Implement a migration path for changing Keychain accessibility levels, as changing the level requires deleting and re-adding the item. Log Keychain error codes during development to catch issues early -- many operations fail silently if you only check for errSecSuccess.
Conclusion
The iOS Keychain is a powerful security primitive, but its C-based API and implicit behaviors make it easy to misuse. By wrapping it in a type-safe Swift API, handling edge cases like duplicate items and background access, and integrating biometric authentication properly, KlivvrStorageKit makes Keychain Services reliable and developer-friendly. The Keychain should be your default choice for storing any sensitive data on iOS -- tokens, credentials, encryption keys, and personal information all belong in the Keychain, not in UserDefaults or plain files.
Related Articles
Building Internal SDKs That Developers Want to Use
Lessons from building KlivvrStorageKit on creating internal SDKs with great developer experience — covering API design, documentation, migration support, and adoption strategies.
Encryption at Rest: Protecting Data on iOS Devices
Implement encryption at rest for iOS applications using Apple's CryptoKit, AES-GCM, and the Secure Enclave, with practical Swift code examples.
Secure Storage on iOS: Keychain, UserDefaults, and Beyond
Compare iOS storage options including Keychain, UserDefaults, file system, and Core Data, with guidance on choosing the right storage mechanism for sensitive data.