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.

technical8 min readBy Klivvr Engineering
Share:

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 flags

Use 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 differently

Practical 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

business

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.

6 min read