Keychain Services in Swift: The Complete Guide to Secure Storage in iOS 26
Keychain Services is Apple's hardware-backed secure storage API for saving passwords, tokens, and cryptographic keys. This iOS 26 guide covers SecItemAdd, access control, biometric protection with LocalAuthentication, iCloud Keychain sync, and Secure Enclave keys with CryptoKit.
Keychain Services is Apple's built-in, hardware-backed secure storage API for saving passwords, tokens, cryptographic keys, and certificates in Swift apps across iOS, macOS, visionOS, watchOS, and tvOS. Unlike UserDefaults, items are encrypted at rest, tied to your app's identity, and survive app reinstalls when iCloud sync is enabled. In iOS 26 with Swift 6, the C-based SecItem* functions still ship as the canonical API, but they now pair cleanly with typed throws, actors, and CryptoKit's Secure Enclave keys for modern secure storage patterns.
Keychain Services stores small pieces of sensitive data (under about 4 KB per item) in an encrypted database managed by securityd, protected by the device's hardware Secure Enclave on all modern Apple platforms.
All access happens through four C functions (SecItemAdd, SecItemCopyMatching, SecItemUpdate, SecItemDelete) that take a CFDictionary query and return an OSStatus.
Use kSecAttrAccessible and SecAccessControl to decide when items can be read: never after first unlock, only while the device is unlocked, or only after biometric authentication.
Share items between an app and its extensions with a Keychain Access Group entitlement; sync passwords across a user's devices with kSecAttrSynchronizable.
Secure Enclave-backed private keys can be created with SecKeyCreateRandomKey or CryptoKit's SecureEnclave.P256. The private key material never leaves the chip.
macOS keychains behave differently from iOS: multiple keychain files, per-item ACL prompts, and legacy vs. data-protection keychain distinctions matter when you ship a Mac Catalyst or native macOS target.
What is Keychain Services in Swift?
Keychain Services is a low-level C API exposed through the Security framework that reads and writes small, sensitive items (passwords, session tokens, API keys, symmetric keys, private keys, and certificates) to an encrypted database managed by the securityd daemon on iOS, iPadOS, visionOS, watchOS, and tvOS, and by SecurityAgent on macOS. On devices with a Secure Enclave (every shipping iPhone since 2013, every Mac with Apple silicon, every Apple Watch), the master keys that decrypt keychain contents are wrapped by hardware and only unwrapped when the device unlock state matches the item's protection class.
From Swift 6's perspective, the API surface is deliberately unglamorous. You build a CFDictionary of attributes (the item class, an account identifier, a service string, the payload data) and pass it to one of four functions: SecItemAdd, SecItemCopyMatching, SecItemUpdate, SecItemDelete. Each returns an OSStatus integer that you match against constants like errSecSuccess, errSecItemNotFound, or errSecDuplicateItem. There is no async version. There is no Codable wrapper. That's your job, and once you accept that, everything else falls into place.
Honestly, the reason Apple hasn't modernized the surface with a Swifty wrapper is that the semantics genuinely matter. Choosing the wrong item class or accessibility level is a security bug you cannot detect at compile time; it silently changes where and when your data can be read. In my experience shipping Keychain code across the Apple platforms, the strongest habit is to write a small internal wrapper per app with typed errors, then never touch SecItem* outside that file. See the Keychain Services reference for the authoritative attribute list; the underlying threat model is documented in the Apple Platform Security guide.
Keychain vs UserDefaults: when to use each
New Swift developers frequently ask whether to save an auth token in UserDefaults. The short answer: never. UserDefaults is a plaintext plist inside your app container, and anyone with a jailbreak, a device backup, or a macOS Finder inspection can read it. Keychain, by contrast, is encrypted with keys the OS refuses to release except under the conditions you specify. Here is how the two APIs compare across the dimensions that actually matter for a shipping app.
Dimension
Keychain Services
UserDefaults
Encryption at rest
Yes, hardware-backed on devices with Secure Enclave
No, plaintext plist in the app container
Access control (unlock state, biometrics)
Yes, per item, via kSecAttrAccessible and SecAccessControl
None
Survives app uninstall
Yes on iOS (unless the user reinstalls after iOS 10.3+ policy)
No, deleted with the app
Syncs across user's devices
Yes, opt-in via kSecAttrSynchronizable
Only with NSUbiquitousKeyValueStore, not the same key
Recommended payload size
Small (a few KB per item), not a blob store
Small values only; large blobs belong in Files
Sharable with app extensions
Yes, via Keychain Access Groups entitlement
Yes, via App Groups suite
API ergonomics in Swift
C-style SecItem*, requires a wrapper
Codable/@AppStorage, one-liner
The rule I follow: if the data would embarrass a user, cost you money, or grant access to a system on its behalf (token, refresh token, password, subscription receipt, encryption key, biometric-gated secret), it goes to Keychain. Preferences, UI state, feature flags, non-sensitive user choices? UserDefaults is fine and vastly easier to use.
How do you save a password to Keychain in Swift?
The minimum viable "save a password" call is three attributes: an item class (kSecClassGenericPassword), a service string that identifies your app or feature, and an account string that identifies the user. Add the value data and call SecItemAdd. Here's a production-shaped wrapper I paste into most new projects; it uses Swift 6 typed throws so callers see exactly which failure modes to handle. If you're new to throws(SpecificError) syntax, the typed throws in Swift 6 guide covers the ergonomics.
import Foundation
import Security
enum KeychainError: Error {
case itemNotFound
case duplicateItem
case invalidData
case unhandled(OSStatus)
}
struct KeychainStore {
let service: String
func save(_ data: Data, account: String) throws(KeychainError) {
let query: [String: Any] = [
kSecClass as String: kSecClassGenericPassword,
kSecAttrService as String: service,
kSecAttrAccount as String: account,
kSecAttrAccessible as String: kSecAttrAccessibleAfterFirstUnlockThisDeviceOnly,
kSecValueData as String: data
]
let status = SecItemAdd(query as CFDictionary, nil)
switch status {
case errSecSuccess: return
case errSecDuplicateItem: throw .duplicateItem
default: throw .unhandled(status)
}
}
func saveString(_ value: String, account: String) throws(KeychainError) {
guard let data = value.data(using: .utf8) else { throw .invalidData }
try save(data, account: account)
}
}
Two attribute choices deserve a note. kSecAttrService is a namespace. I typically use my reverse-DNS bundle prefix plus a feature name, for example "dev.swiftcrafted.demo.authToken". kSecAttrAccount distinguishes multiple items in the same service; for a single-user app it can be an email or a stable user ID. The combination (service, account) is the primary key of a generic-password item, which is why errSecDuplicateItem fires when you try to add the same pair twice. You must call SecItemUpdate or delete-then-add instead.
Reading, updating, and deleting Keychain items
Reading an item uses the same attribute dictionary, plus three flags that describe what you want back: kSecReturnData for the payload, kSecReturnAttributes for the metadata, and kSecMatchLimit to say "exactly one" or "all matches." SecItemCopyMatching writes the result into a CFTypeRef? out-pointer that you cast on the Swift side.
extension KeychainStore {
func read(account: String) throws(KeychainError) -> Data {
let query: [String: Any] = [
kSecClass as String: kSecClassGenericPassword,
kSecAttrService as String: service,
kSecAttrAccount as String: account,
kSecReturnData as String: true,
kSecMatchLimit as String: kSecMatchLimitOne
]
var result: CFTypeRef?
let status = SecItemCopyMatching(query as CFDictionary, &result)
switch status {
case errSecSuccess:
guard let data = result as? Data else { throw .invalidData }
return data
case errSecItemNotFound: throw .itemNotFound
default: throw .unhandled(status)
}
}
func update(_ data: Data, account: String) throws(KeychainError) {
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)
switch status {
case errSecSuccess: return
case errSecItemNotFound: throw .itemNotFound
default: throw .unhandled(status)
}
}
func delete(account: String) throws(KeychainError) {
let query: [String: Any] = [
kSecClass as String: kSecClassGenericPassword,
kSecAttrService as String: service,
kSecAttrAccount as String: account
]
let status = SecItemDelete(query as CFDictionary)
guard status == errSecSuccess || status == errSecItemNotFound else {
throw .unhandled(status)
}
}
}
Access control and Data Protection classes
The kSecAttrAccessible attribute is the single most consequential choice you make when writing a Keychain item. It controls when the OS is willing to decrypt the value, and it cannot be inspected at runtime by other apps. Once written, it's the OS's answer to "can I return this data right now?" There are six commonly used constants:
kSecAttrAccessibleWhenUnlocked: readable only while the device is currently unlocked. Best default for interactive UI-triggered reads.
kSecAttrAccessibleWhenUnlockedThisDeviceOnly: same as above, but the item is never included in encrypted backups or iCloud Keychain sync. Use for device-bound secrets like an app-generated device ID.
kSecAttrAccessibleAfterFirstUnlock: readable after the user unlocks the device once after boot, then remains readable until reboot. Required for background tasks and push handlers that run before the user opens the app.
kSecAttrAccessibleAfterFirstUnlockThisDeviceOnly: same, without backup. My default for OAuth tokens.
kSecAttrAccessibleWhenPasscodeSetThisDeviceOnly: item exists only if the user has set a passcode; removing the passcode deletes the item. Use for high-value secrets tied to device security posture.
kSecAttrAccessibleAlways and kSecAttrAccessibleAlwaysThisDeviceOnly: deprecated; do not use in new code.
Biometric protection with LocalAuthentication
For a secret that should require Face ID or Touch ID at read time (a payment authorization token, a password-manager master key, a recovery phrase), pair Keychain with the LocalAuthentication framework via a SecAccessControl object. This shifts unlock from "device passcode entered at some point since boot" to "the user just authenticated biometrically for this specific read." The prompt is presented by the OS; you cannot see the biometric data itself.
import LocalAuthentication
import Security
func saveBiometricToken(_ token: Data, service: String, account: String) throws {
var error: Unmanaged<CFError>?
guard let access = SecAccessControlCreateWithFlags(
nil,
kSecAttrAccessibleWhenUnlockedThisDeviceOnly,
[.biometryCurrentSet, .privateKeyUsage],
&error
) else {
throw error!.takeRetainedValue() as Error
}
let context = LAContext()
context.localizedReason = "Unlock your secure token"
let query: [String: Any] = [
kSecClass as String: kSecClassGenericPassword,
kSecAttrService as String: service,
kSecAttrAccount as String: account,
kSecAttrAccessControl as String: access,
kSecUseAuthenticationContext as String: context,
kSecValueData as String: token
]
let status = SecItemAdd(query as CFDictionary, nil)
guard status == errSecSuccess else { throw KeychainError.unhandled(status) }
}
The .biometryCurrentSet flag ties the item to the exact set of enrolled Face ID / Touch ID templates at write time. If the user adds a new fingerprint later, the item is invalidated. The OS will refuse to return it, protecting against an attacker who enrolls their own biometric to steal your data. For lower-friction UX, .biometryAny allows any enrolled biometric to unlock the item; pick the right trade-off for the sensitivity of the secret. See the LocalAuthentication framework docs for the full flag list.
Sharing Keychain items between apps and extensions
Any real app has extensions: a Share Extension, a Widget, a Watch companion, a Notification Service Extension. Each runs in a separate process with its own container, so by default they cannot see each other's Keychain items. Sharing works through a Keychain Access Group, a string declared in the keychain-access-groups entitlement of every process that participates, plus kSecAttrAccessGroup on every read and write.
let query: [String: Any] = [
kSecClass as String: kSecClassGenericPassword,
kSecAttrService as String: "dev.swiftcrafted.demo.authToken",
kSecAttrAccount as String: userID,
kSecAttrAccessGroup as String: "$(AppIdentifierPrefix)dev.swiftcrafted.shared",
kSecValueData as String: tokenData
]
The $(AppIdentifierPrefix) placeholder is your ten-character Team ID; Xcode substitutes it at build time in the entitlements file. On the target signing tab, add the same access-group string under "Keychain Sharing" for every target that needs read access. If your widget calls a REST endpoint using an auth token stored by the host app (the classic networking with URLSession setup), this is how the widget gets the token without re-authenticating the user.
iCloud Keychain synchronization
Setting kSecAttrSynchronizable to true at write time opts the item into iCloud Keychain sync, meaning it flows to every device signed into the same iCloud account with Keychain enabled. This is how a password manager keeps a user's vault in sync across their iPhone, iPad, and Mac without you writing a single line of network code. So, the catch: synchronizable items must use an accessibility class without the ThisDeviceOnly suffix. kSecAttrAccessibleAfterFirstUnlock is fine, but kSecAttrAccessibleAfterFirstUnlockThisDeviceOnly will reject the write with errSecParam.
let query: [String: Any] = [
kSecClass as String: kSecClassGenericPassword,
kSecAttrService as String: "dev.swiftcrafted.demo.vault",
kSecAttrAccount as String: vaultID,
kSecAttrAccessible as String: kSecAttrAccessibleAfterFirstUnlock,
kSecAttrSynchronizable as String: kCFBooleanTrue!,
kSecValueData as String: encryptedVault
]
Secure Enclave keys with CryptoKit
For the strongest storage guarantee, don't store the secret itself. Store a Secure Enclave key that protects the secret instead. The Secure Enclave is an isolated coprocessor with its own memory and its own OS; private key material generated inside it cannot be extracted by anything, including root code on the main CPU. CryptoKit exposes this through SecureEnclave.P256, which mirrors the regular P256 types but never gives you access to the raw private key bytes.
import CryptoKit
// One-time: create a device-bound signing key, save its ANSI representation
let signingKey = try SecureEnclave.P256.Signing.PrivateKey(
accessControl: SecAccessControlCreateWithFlags(
nil,
kSecAttrAccessibleWhenUnlockedThisDeviceOnly,
[.privateKeyUsage, .biometryCurrentSet],
nil
)!
)
try KeychainStore(service: "dev.swiftcrafted.demo.signing")
.save(signingKey.dataRepresentation, account: "user")
// Later: reconstitute and sign a payload without ever seeing the private key
let stored = try KeychainStore(service: "dev.swiftcrafted.demo.signing").read(account: "user")
let key = try SecureEnclave.P256.Signing.PrivateKey(dataRepresentation: stored)
let signature = try key.signature(for: Data("challenge".utf8))
The dataRepresentation is not the private key; it is an opaque handle wrapped by the Enclave. Even though you store it in the Keychain and read it back, only the same Secure Enclave on the same device can actually sign with it. This is the ideal design for passkey-style challenge/response auth and for on-device attestation flows that back on-device AI features requiring proof of user consent.
How Keychain behaves on macOS, visionOS, and watchOS
The API is uniform across Apple platforms, but the runtime behavior isn't, and this is where cross-platform breadth pays off. Here's what to keep in mind for each target:
macOS (native and Catalyst). The Mac has two keychain implementations. The legacy "login" keychain shows the padlock icon in Keychain Access and prompts the user before other apps can read items, unless code signing matches. The data protection keychain (the one iOS uses) was made available to macOS apps in macOS 10.15 by adding kSecUseDataProtectionKeychain to your query. In modern code, always set that flag to true. You get iOS-consistent semantics, sync, and no per-item ACL prompts.
Mac Catalyst. Uses the data protection keychain automatically, but access groups must be listed in the Catalyst target's entitlements and match the iOS entitlements exactly, including the Team ID prefix. A common bug: the iOS app writes to a group, the Catalyst build reads from a different group because Xcode auto-generated a group string.
visionOS. Behaves like iOS. The Secure Enclave in Apple Vision Pro's R1 coprocessor participates in the same protection classes. One difference: biometric prompts use Optic ID, which returns the same LAContext callbacks as Face ID. Your code doesn't change, but your prompt copy should say "look at the badge" not "use Face ID."
watchOS. The watch has its own keychain; items do not automatically flow from the paired iPhone. Sync with iCloud Keychain or replicate the item over Watch Connectivity if you need it locally. Accessibility classes work the same, but AfterFirstUnlock is triggered by unlocking the watch itself, not the phone.
tvOS. No user passcode by default, so avoid WhenPasscodeSet. Use AfterFirstUnlock and design for a shared-device model. Anyone with physical access to the TV can generally read items.
Common errors and how to debug them
Keychain's OSStatus codes are numeric and inscrutable until you memorize a few. The most frequent ones in my Xcode 26 crash reports:
errSecItemNotFound (-25300): no matching item. Almost always a mismatch between the write query and the read query. Print both dictionaries; look for a stray kSecAttrAccessGroup or a kSecAttrSynchronizable flag that flipped.
errSecDuplicateItem (-25299): write collided with an existing item on the same primary key. Delete first or call SecItemUpdate.
errSecInteractionNotAllowed (-25308): device is locked and the item's accessibility class requires an unlocked device. Raise the class to AfterFirstUnlock or skip the read in background code.
errSecAuthFailed (-25293): biometric failed or was cancelled. Show a fallback UI; don't retry silently.
errSecMissingEntitlement (-34018): access group entitlement is missing from the target's signing profile. Rebuild after fixing the entitlements file; a Clean Build Folder is often required because Xcode caches entitlement dictionaries.
For a human-readable message from any OSStatus, call SecCopyErrorMessageString(status, nil). This returns a CFString that Apple ships with the Security framework. It won't fix the bug, but it turns "-25293" into "The user name or passphrase you entered is not correct," which makes reading logs from TestFlight users significantly less painful.
Frequently Asked Questions
Is data stored in the iOS Keychain encrypted?
Yes. All Keychain items on iOS, iPadOS, watchOS, tvOS, and visionOS are encrypted at rest using AES-256 keys derived from a device-unique hardware key inside the Secure Enclave. On Macs with Apple silicon, the data protection keychain uses the same model; the legacy macOS keychain also encrypts items but uses a different key hierarchy tied to the user's login password.
Does the Keychain persist when the app is uninstalled?
On iOS 10.3 and later, Keychain items belonging to an uninstalled app are deleted along with the app. Before iOS 10.3 they persisted, which is why older tutorials sometimes claim the Keychain "survives reinstall." If you need cross-install persistence today, opt the item into iCloud Keychain sync. It will be restored from the user's account after reinstall.
How do I share Keychain items between my app and its widget?
Enable the "Keychain Sharing" capability on both targets and declare the same access-group string on each. Then include kSecAttrAccessGroup in every Keychain query on both sides. Both targets must be signed with the same Team ID, and the access group string in the entitlements must include the ten-character team prefix.
What is the maximum size of a Keychain item?
Apple documents no hard limit, but in practice keep individual items under about 4 KB. The Keychain is designed for passwords, tokens, and keys, not blobs. If you need to store an encrypted file, generate a symmetric key with CryptoKit, save the key to the Keychain, and write the encrypted file to the app container or a shared App Group container.
Can I use the Keychain from a Swift command-line tool on macOS?
Yes, but only against the legacy login keychain unless the binary is code-signed with a bundle identifier and entitlements. Unsigned tools cannot use the data protection keychain and cannot participate in access groups. For a shipping macOS CLI, sign it with a Developer ID certificate and add a keychain-access-groups entitlement just like an app target.
How to add Face ID, Touch ID, and Optic ID to a SwiftUI app on iOS 26 using LocalAuthentication, with LAPolicy tips, LAError handling, and Keychain binding for secrets.
A hands-on guide to SwiftUI KeyframeAnimator in iOS 26: multi-track parametric keyframes, when to reach for it over PhaseAnimator, and how to sync motion with haptics.
Master the SwiftUI toolbar in iOS 26. ToolbarItem placements, keyboard bars, Liquid Glass styling, VoiceOver, and cross-platform behavior on iPhone, iPad, and Mac.