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.
iOS provides multiple storage mechanisms, each with different security characteristics, performance profiles, and use cases. Choosing the wrong storage mechanism is a common security mistake -- storing authentication tokens in UserDefaults, for example, leaves them readable by anyone with physical access to a jailbroken device. Understanding the security guarantees of each storage option is essential for protecting user data.
This article compares the major iOS storage options, explains their security properties, and shows how KlivvrStorageKit provides a unified abstraction that selects the right storage backend based on data sensitivity.
UserDefaults: Convenience at a Cost
UserDefaults is the simplest storage API on iOS. It is backed by a plist file and provides fast key-value access. However, it offers no encryption and is not suitable for sensitive data.
// UserDefaults: simple but not secure
UserDefaults.standard.set("user_preference", forKey: "theme")
let theme = UserDefaults.standard.string(forKey: "theme")
// The plist file is readable on jailbroken devices
// Located at: /var/mobile/Containers/Data/Application/<UUID>/Library/Preferences/
// What NOT to store in UserDefaults:
// - Authentication tokens
// - API keys
// - Passwords or PINs
// - Personal identifiable information (PII)
// - Financial dataUserDefaults is appropriate for non-sensitive preferences like theme selection, notification settings, and feature flags. Never store anything you would not want displayed on a billboard.
Keychain Services: The Security Foundation
The iOS Keychain is a hardware-backed encrypted storage system designed specifically for sensitive data. It survives app reinstalls (with the right configuration), integrates with biometric authentication, and is protected by the device's Secure Enclave.
import Security
// Raw Keychain API: functional but verbose
func saveToKeychain(key: String, data: Data) -> OSStatus {
let query: [String: Any] = [
kSecClass as String: kSecClassGenericPassword,
kSecAttrAccount as String: key,
kSecValueData as String: data,
kSecAttrAccessible as String: kSecAttrAccessibleWhenUnlockedThisDeviceOnly
]
// Delete existing item first
SecItemDelete(query as CFDictionary)
return SecItemAdd(query as CFDictionary, nil)
}
func readFromKeychain(key: String) -> Data? {
let query: [String: Any] = [
kSecClass as String: kSecClassGenericPassword,
kSecAttrAccount as String: key,
kSecReturnData as String: true,
kSecMatchLimit as String: kSecMatchLimitOne
]
var result: AnyObject?
let status = SecItemCopyMatching(query as CFDictionary, &result)
guard status == errSecSuccess else { return nil }
return result as? Data
}
func deleteFromKeychain(key: String) -> OSStatus {
let query: [String: Any] = [
kSecClass as String: kSecClassGenericPassword,
kSecAttrAccount as String: key
]
return SecItemDelete(query as CFDictionary)
}The raw Keychain API is notoriously cumbersome. Every operation requires constructing a dictionary with Security framework constants, and error handling involves parsing OSStatus codes. This is why wrapper libraries like KlivvrStorageKit exist.
Keychain Access Control
The Keychain supports fine-grained access control through accessibility constants and access control flags. These determine when data is accessible and whether biometric authentication is required.
// Keychain accessibility levels
enum KeychainAccessibility {
case whenUnlocked // Available when device is unlocked
case afterFirstUnlock // Available after first unlock until restart
case whenUnlockedThisDevice // Same as whenUnlocked but not backed up
case afterFirstUnlockThisDevice // Same as afterFirstUnlock, not backed up
case whenPasscodeSet // Only when device has a passcode set
var secAttr: CFString {
switch self {
case .whenUnlocked:
return kSecAttrAccessibleWhenUnlocked
case .afterFirstUnlock:
return kSecAttrAccessibleAfterFirstUnlock
case .whenUnlockedThisDevice:
return kSecAttrAccessibleWhenUnlockedThisDeviceOnly
case .afterFirstUnlockThisDevice:
return kSecAttrAccessibleAfterFirstUnlockThisDeviceOnly
case .whenPasscodeSet:
return kSecAttrAccessibleWhenPasscodeSetThisDeviceOnly
}
}
}
// Biometric-protected Keychain access
func saveBiometricProtected(key: String, data: Data) throws {
var error: Unmanaged<CFError>?
guard let accessControl = SecAccessControlCreateWithFlags(
nil,
kSecAttrAccessibleWhenUnlockedThisDeviceOnly,
[.biometryCurrentSet, .or, .devicePasscode],
&error
) else {
throw StorageError.accessControlCreationFailed
}
let query: [String: Any] = [
kSecClass as String: kSecClassGenericPassword,
kSecAttrAccount as String: key,
kSecValueData as String: data,
kSecAttrAccessControl as String: accessControl,
kSecUseAuthenticationContext as String: LAContext()
]
SecItemDelete(query as CFDictionary)
let status = SecItemAdd(query as CFDictionary, nil)
guard status == errSecSuccess else {
throw StorageError.keychainError(status)
}
}File System Storage with Data Protection
For larger data that does not fit in the Keychain, iOS file system storage with Data Protection provides encryption at rest.
// File system storage with encryption
final class SecureFileStorage {
private let baseDirectory: URL
init() throws {
let appSupport = FileManager.default.urls(
for: .applicationSupportDirectory,
in: .userDomainMask
).first!
baseDirectory = appSupport.appendingPathComponent("SecureStorage", isDirectory: true)
try FileManager.default.createDirectory(
at: baseDirectory,
withIntermediateDirectories: true
)
// Set directory protection level
try (baseDirectory as NSURL).setResourceValue(
URLFileProtection.complete,
forKey: .fileProtectionKey
)
}
func write(_ data: Data, forKey key: String) throws {
let fileURL = baseDirectory.appendingPathComponent(key.sha256Hash)
try data.write(to: fileURL, options: .completeFileProtection)
}
func read(forKey key: String) throws -> Data? {
let fileURL = baseDirectory.appendingPathComponent(key.sha256Hash)
guard FileManager.default.fileExists(atPath: fileURL.path) else {
return nil
}
return try Data(contentsOf: fileURL)
}
func delete(forKey key: String) throws {
let fileURL = baseDirectory.appendingPathComponent(key.sha256Hash)
if FileManager.default.fileExists(atPath: fileURL.path) {
try FileManager.default.removeItem(at: fileURL)
}
}
}File protection levels mirror Keychain accessibility: .complete encrypts files when the device is locked, .completeUnlessOpen allows writing while locked if the file was opened while unlocked, and .completeUntilFirstUserAuthentication keeps files accessible after the first unlock.
KlivvrStorageKit: Unified Storage Abstraction
KlivvrStorageKit abstracts these storage mechanisms behind a unified API that automatically selects the appropriate backend based on data sensitivity.
// Unified storage API
public final class SecureStorage {
public static let shared = SecureStorage()
private let keychainStore: KeychainStore
private let encryptedFileStore: EncryptedFileStore
private let preferencesStore: PreferencesStore
public enum SecurityLevel {
case sensitive // Keychain (tokens, passwords, keys)
case `private` // Encrypted file system (user data, documents)
case standard // UserDefaults (preferences, settings)
}
public func store<T: Codable>(
_ value: T,
forKey key: String,
security: SecurityLevel = .private
) throws {
let data = try JSONEncoder().encode(value)
switch security {
case .sensitive:
try keychainStore.save(data: data, forKey: key)
case .private:
try encryptedFileStore.write(data, forKey: key)
case .standard:
preferencesStore.set(data, forKey: key)
}
}
public func retrieve<T: Codable>(
_ type: T.Type,
forKey key: String,
security: SecurityLevel = .private
) throws -> T? {
let data: Data?
switch security {
case .sensitive:
data = try keychainStore.read(forKey: key)
case .private:
data = try encryptedFileStore.read(forKey: key)
case .standard:
data = preferencesStore.data(forKey: key)
}
guard let data else { return nil }
return try JSONDecoder().decode(T.self, from: data)
}
public func delete(forKey key: String, security: SecurityLevel = .private) throws {
switch security {
case .sensitive:
try keychainStore.delete(forKey: key)
case .private:
try encryptedFileStore.delete(forKey: key)
case .standard:
preferencesStore.removeObject(forKey: key)
}
}
}
// Usage
struct UserCredentials: Codable {
let accessToken: String
let refreshToken: String
let expiresAt: Date
}
// Store credentials securely in Keychain
try SecureStorage.shared.store(
credentials,
forKey: "user_credentials",
security: .sensitive
)
// Retrieve credentials
let credentials = try SecureStorage.shared.retrieve(
UserCredentials.self,
forKey: "user_credentials",
security: .sensitive
)Practical Tips
Never store sensitive data in UserDefaults -- always use the Keychain. Use kSecAttrAccessibleAfterFirstUnlockThisDeviceOnly for data that background processes need (like push notification tokens). Use kSecAttrAccessibleWhenUnlockedThisDeviceOnly for data that should only be available when the user is actively using the device. Add the ThisDeviceOnly suffix to prevent sensitive data from being included in backups and transferred to other devices. Always test Keychain behavior on real devices, as the Simulator has different Keychain behavior.
Conclusion
iOS provides a spectrum of storage options from the simple UserDefaults to the hardware-backed Keychain. Understanding the security characteristics of each is essential for protecting user data. KlivvrStorageKit eliminates the complexity of choosing and implementing the right storage backend by providing a type-safe, unified API with automatic security level routing. Store the data, specify the sensitivity, and let the SDK handle the rest.
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.
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.
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.