Introduction
By day 40 in your iOS development journey, you've built UI flows, managed state, and handled network requests. But have you stored an API token, password, or authentication certificate safely? That's where the Keychain comes in—and it's critical for any production app handling sensitive data. In this guide, we'll cover Keychain basics: Swift & iOS best practices explained, with concrete patterns you can use today to secure tokens, passwords, and biometric-protected secrets across your users' devices.
Why Keychain Matters for Tokens and Passwords
The filesystem and UserDefaults are convenient for storing app settings, but they're not encrypted by default. If a device is compromised or a backup is extracted, plain-text tokens and passwords are exposed. The Keychain is the iOS system-level secure storage designed specifically for this: it encrypts data on disk, ties it to device credentials, and provides access control policies.
In production apps we've built across e-commerce, trading, and SaaS platforms, storing authentication tokens in UserDefaults has been the number one cause of security incidents. A malicious actor, USB forensics, or even a developer debugging can read it. The Keychain, by contrast, is isolated and requires explicit API calls to retrieve data.
Consider a typical OAuth flow: after login, you receive a refresh token with a long expiry and an access token valid for hours. Store the refresh token in Keychain. When the access token expires, use the refresh token (retrieved from Keychain) to get a new access token. Never store sensitive tokens in UserDefaults, plain files, or even in-memory strings beyond their immediate use.
Access Control with Biometrics
Beyond encryption at rest, Keychain lets you require biometric authentication (Face ID or Touch ID) before retrieving sensitive items. This adds a layer of user verification: even if an attacker gains device access, they can't retrieve protected Keychain items without the user's fingerprint or face.
When you set an access control policy requiring biometric authentication, the Keychain blocks retrieval until the user authenticates. This is invaluable for high-risk operations: deleting an account, transferring funds, or accessing admin credentials.
The basic approach is to use SecAccessControlCreateWithFlags with userPresence, biometryAny, or biometryCurrentSet flags. Here's a concrete example:
import Security
func saveTokenWithBiometric(_ token: String, account: String) throws {
let data = token.data(using: .utf8)!
var accessControl: SecAccessControl?
var error: Unmanaged<CFError>?
accessControl = SecAccessControlCreateWithFlags(
kCFAllocatorDefault,
kSecAttrAccessibleWhenPasscodeSetThisDeviceOnly,
.biometryAny, // Allow any enrolled biometric
&error
)
guard accessControl != nil else {
throw KeychainError.accessControlCreationFailed
}
let query: [String: Any] = [
kSecClass as String: kSecClassGenericPassword,
kSecAttrAccount as String: account,
kSecValueData as String: data,
kSecAttrAccessControl as String: accessControl!,
kSecAttrSynchronizable as String: false
]
SecItemDelete(query as CFDictionary)
let status = SecItemAdd(query as CFDictionary, nil)
guard status == errSecSuccess else {
throw KeychainError.saveFailed(status)
}
}
When retrieving, the system will prompt for biometric authentication automatically if required:
func retrieveTokenRequiringBiometric(account: String) throws -> String {
let query: [String: Any] = [
kSecClass as String: kSecClassGenericPassword,
kSecAttrAccount as String: account,
kSecReturnData as String: true,
kSecUseOperationPrompt as String: "Authenticate to access your token"
]
var result: AnyObject?
let status = SecItemCopyMatching(query as CFDictionary, &result)
guard status == errSecSuccess, let data = result as? Data else {
throw KeychainError.retrieveFailed(status)
}
return String(data: data, encoding: .utf8) ?? ""
}
In ride-hailing and travel apps, we've used this pattern for storing driver credentials and payment tokens. Users authenticate once, then the Keychain enforces re-authentication for sensitive operations—balancing security with user experience.
Use a Small Wrapper for Consistency
Raw Keychain APIs are verbose and error-prone. Every call involves dictionary construction, type casting, and status code checking. Build a lightweight wrapper to standardize access across your team and reduce bugs.
Here's a practical wrapper used in production:
import Security
enum KeychainError: Error {
case saveFailed(OSStatus)
case retrieveFailed(OSStatus)
case deleteFailed(OSStatus)
case accessControlCreationFailed
case encodingFailed
}
class KeychainManager {
static let shared = KeychainManager()
func save(
_ value: String,
for key: String,
requireBiometric: Bool = false
) throws {
guard let data = value.data(using: .utf8) else {
throw KeychainError.encodingFailed
}
var query: [String: Any] = [
kSecClass as String: kSecClassGenericPassword,
kSecAttrAccount as String: key,
kSecValueData as String: data,
kSecAttrAccessible as String: kSecAttrAccessibleWhenUnlockedThisDeviceOnly
]
if requireBiometric {
var error: Unmanaged<CFError>?
guard let accessControl = SecAccessControlCreateWithFlags(
kCFAllocatorDefault,
kSecAttrAccessibleWhenUnlockedThisDeviceOnly,
.biometryAny,
&error
) else {
throw KeychainError.accessControlCreationFailed
}
query[kSecAttrAccessControl as String] = accessControl
}
SecItemDelete(query as CFDictionary)
let status = SecItemAdd(query as CFDictionary, nil)
guard status == errSecSuccess else {
throw KeychainError.saveFailed(status)
}
}
func retrieve(for key: String) throws -> String {
let query: [String: Any] = [
kSecClass as String: kSecClassGenericPassword,
kSecAttrAccount as String: key,
kSecReturnData as String: true
]
var result: AnyObject?
let status = SecItemCopyMatching(query as CFDictionary, &result)
guard status == errSecSuccess, let data = result as? Data else {
throw KeychainError.retrieveFailed(status)
}
guard let value = String(data: data, encoding: .utf8) else {
throw KeychainError.encodingFailed
}
return value
}
func delete(for key: String) throws {
let query: [String: Any] = [
kSecClass as String: kSecClassGenericPassword,
kSecAttrAccount as String: key
]
let status = SecItemDelete(query as CFDictionary)
guard status == errSecSuccess || status == errSecItemNotFound else {
throw KeychainError.deleteFailed(status)
}
}
}
Usage becomes clean and consistent across your codebase:
// Save a token securely
try KeychainManager.shared.save(refreshToken, for: "refresh_token")
// Retrieve it
let token = try KeychainManager.shared.retrieve(for: "refresh_token")
// Save a payment token requiring biometric
try KeychainManager.shared.save(
paymentToken,
for: "payment_token",
requireBiometric: true
)
// Delete on logout
try KeychainManager.shared.delete(for: "refresh_token")
This pattern scales: you add error handling, logging, and team-wide conventions in one place. Everyone on the team uses the same, vetted approach.
Data Persists Across Reinstalls Unless Removed
An important characteristic: Keychain data persists across app reinstalls and iOS updates on the same device. This is usually desirable—users won't need to re-authenticate after updating your app. However, it's something to account for in your logout flow.
When a user explicitly logs out, you must delete all sensitive Keychain items. Otherwise, if the app is reinstalled, the old tokens might still be there, potentially granting access to the wrong account or stale credentials.
A best practice is to maintain a list of all Keychain keys your app uses and delete them on logout:
enum KeychainKey: String, CaseIterable {
case refreshToken = "refresh_token"
case accessToken = "access_token"
case paymentMethod = "payment_method"
case userID = "user_id"
}
func logoutAndCleanKeychain() throws {
for key in KeychainKey.allCases {
try KeychainManager.shared.delete(for: key.rawValue)
}
// Clear other session state
UserDefaults.standard.removeObject(forKey: "currentUserEmail")
}
In apps handling user accounts (e-commerce, SaaS, trading), we've seen bugs where reinstalling an app left old tokens in place. A second user installs on the same device, and they can inadvertently access the previous user's account. Explicit deletion on logout prevents this entirely.
Also consider: if you support app backup and restore via iCloud, be aware that Keychain items marked kSecAttrAccessibleWhenUnlockedThisDeviceOnly do not sync, which is correct for sensitive auth data. Items marked kSecAttrAccessibleWhenUnlocked may sync, so prefer device-only accessibility for tokens.
Common Mistakes
Storing Keychain values in memory without clearing: After retrieving a token from Keychain, don't store it in a long-lived property or global. Use it immediately, then let it go out of scope so the string is deallocated. Sensitive data lingering in memory can be extracted by debugging tools or memory dumps.
Not deleting on logout: As mentioned, forget to wipe Keychain on logout and you'll have persistent auth data across reinstalls. Always explicitly delete sensitive items when the session ends.
Mixing Keychain and UserDefaults for related data: Store the token in Keychain but the user ID or email in UserDefaults. If they're always used together, keep them in sync or store both in Keychain to avoid orphaned data.
Ignoring access control flags: Using kSecAttrAccessibleAlways or forgetting to set ThisDeviceOnly can allow data to sync to iCloud or remain accessible after device lock. Be explicit about accessibility requirements.
Not handling errSecItemNotFound: When retrieving, the Keychain may return errSecItemNotFound if the item doesn't exist (e.g., first launch). Handle this gracefully; don't crash or log it as a fatal error unless it's truly unexpected.
Key Takeaways
- Always store tokens, passwords, and certificates in Keychain, not UserDefaults or files.
- Use biometric access control (
.biometryAny) for high-risk operations to add a verification layer. - Build a small wrapper class to standardize Keychain usage across your team and reduce boilerplate and errors.
- Explicitly delete all Keychain items on logout to prevent data persistence across reinstalls and account confusion.
- Use
ThisDeviceOnlyaccessibility for sensitive auth data to prevent unintended iCloud sync. - Retrieve Keychain values only when needed, don't cache them in memory for long periods.
- Handle
errSecItemNotFoundgracefully; it's a normal state on first launch.






Comments
Loading comments…