Biometric-Protected Storage on iOS
How to integrate Face ID and Touch ID with secure local storage on iOS, covering LAContext configuration, access control policies, and fallback strategies used in KlivvrStorageKit.
Biometric authentication adds a physical layer of security to stored data. Even if an attacker gains access to the device, they cannot read biometric-protected Keychain items without the owner's face or fingerprint. For a banking app, this is not optional — sensitive data like authentication tokens, payment credentials, and personal documents must be protected by more than just the device passcode.
This article covers how KlivvrStorageKit integrates biometric authentication with its storage layer, including access control configuration, graceful fallback handling, and the subtle edge cases that trip up most implementations.
LAContext and Biometric Evaluation
The LocalAuthentication framework's LAContext is the gateway to biometric authentication on iOS. Before accessing biometric-protected storage, the app must evaluate whether the device supports biometrics and prompt the user for authentication.
import LocalAuthentication
final class BiometricAuthenticator {
enum BiometricType {
case faceID
case touchID
case none
}
var availableBiometric: BiometricType {
let context = LAContext()
var error: NSError?
guard context.canEvaluatePolicy(
.deviceOwnerAuthenticationWithBiometrics,
error: &error
) else {
return .none
}
switch context.biometryType {
case .faceID: return .faceID
case .touchID: return .touchID
case .opticID: return .faceID // Treat Vision Pro similarly
@unknown default: return .none
}
}
func authenticate(reason: String) async throws -> LAContext {
let context = LAContext()
context.localizedCancelTitle = "Use Passcode"
context.localizedFallbackTitle = "Enter Passcode"
// Invalidate previous context
context.invalidate()
let success = try await context.evaluatePolicy(
.deviceOwnerAuthenticationWithBiometrics,
localizedReason: reason
)
guard success else {
throw BiometricError.authenticationFailed
}
return context
}
}
enum BiometricError: Error {
case authenticationFailed
case biometryNotAvailable
case biometryNotEnrolled
case biometryLockout
case userCancelled
}Biometric-Protected Keychain Items
The Keychain supports biometric protection through SecAccessControl flags. When a Keychain item is created with biometric access control, the system requires biometric authentication before returning the item's data.
final class BiometricKeychain {
func store(
data: Data,
forKey key: String,
requireBiometric: Bool = true
) throws {
var accessFlags: SecAccessControlCreateFlags = [.privateKeyUsage]
if requireBiometric {
accessFlags.insert(.biometryCurrentSet)
}
var error: Unmanaged<CFError>?
guard let accessControl = SecAccessControlCreateWithFlags(
nil,
kSecAttrAccessibleWhenUnlockedThisDeviceOnly,
accessFlags,
&error
) else {
throw BiometricError.biometryNotAvailable
}
let query: [String: Any] = [
kSecClass as String: kSecClassGenericPassword,
kSecAttrAccount as String: key,
kSecAttrService as String: "com.klivvr.storage",
kSecValueData as String: data,
kSecAttrAccessControl as String: accessControl
]
// Delete existing item
SecItemDelete(query as CFDictionary)
let status = SecItemAdd(query as CFDictionary, nil)
guard status == errSecSuccess else {
throw KeychainError.storeFailed(status)
}
}
func retrieve(
forKey key: String,
context: LAContext? = nil
) throws -> Data? {
var query: [String: Any] = [
kSecClass as String: kSecClassGenericPassword,
kSecAttrAccount as String: key,
kSecAttrService as String: "com.klivvr.storage",
kSecReturnData as String: true,
kSecMatchLimit as String: kSecMatchLimitOne
]
// Pass authenticated context to avoid re-prompting
if let context {
query[kSecUseAuthenticationContext as String] = context
}
var result: AnyObject?
let status = SecItemCopyMatching(query as CFDictionary, &result)
switch status {
case errSecSuccess:
return result as? Data
case errSecItemNotFound:
return nil
case errSecAuthFailed:
throw BiometricError.authenticationFailed
case errSecUserCanceled:
throw BiometricError.userCancelled
default:
throw KeychainError.retrieveFailed(status)
}
}
}
enum KeychainError: Error {
case storeFailed(OSStatus)
case retrieveFailed(OSStatus)
}Biometry Current Set vs Any
A critical design decision is choosing between .biometryCurrentSet and .biometryAny. The difference matters for security:
// .biometryCurrentSet: invalidated if biometrics change
// Use for: authentication tokens, payment credentials
// If the user adds a new fingerprint or re-enrolls Face ID,
// existing items become inaccessible and must be re-authenticated
let strictAccess = SecAccessControlCreateWithFlags(
nil,
kSecAttrAccessibleWhenUnlockedThisDeviceOnly,
.biometryCurrentSet,
&error
)
// .biometryAny: survives biometric enrollment changes
// Use for: app preferences, cached non-sensitive data
// Items remain accessible even if biometrics are re-enrolled
let flexibleAccess = SecAccessControlCreateWithFlags(
nil,
kSecAttrAccessibleWhenUnlockedThisDeviceOnly,
.biometryAny,
&error
)KlivvrStorageKit uses .biometryCurrentSet for financial credentials and authentication tokens. If a user's biometrics change — which could indicate the device has been compromised — the app requires re-authentication through the full login flow rather than trusting the new biometric enrollment.
Fallback and Recovery
Biometric authentication can fail for many reasons: the user cancels, the sensor fails, biometrics are locked out after too many attempts, or biometrics are not enrolled. Each scenario requires a graceful fallback.
final class BiometricStorageManager {
private let biometricKeychain: BiometricKeychain
private let authenticator: BiometricAuthenticator
private let fallbackKeychain: KeychainStore
func retrieveSensitive<T: Codable>(
_ type: T.Type,
forKey key: String,
reason: String
) async throws -> T? {
do {
// Attempt biometric authentication
let context = try await authenticator.authenticate(reason: reason)
// Use authenticated context to read from Keychain
guard let data = try biometricKeychain.retrieve(
forKey: key,
context: context
) else {
return nil
}
return try JSONDecoder().decode(T.self, from: data)
} catch let error as LAError {
switch error.code {
case .biometryNotAvailable, .biometryNotEnrolled:
// Device does not support biometrics
// Fall back to passcode-protected storage
return try fallbackToPasscode(type, forKey: key)
case .biometryLockout:
// Too many failed attempts
// Require device passcode to unlock
return try await fallbackToDevicePasscode(
type, forKey: key, reason: reason
)
case .userCancel, .appCancel:
// User cancelled - do not fallback, respect the cancellation
throw BiometricError.userCancelled
case .authenticationFailed:
// Biometric did not match
throw BiometricError.authenticationFailed
default:
throw error
}
}
}
private func fallbackToPasscode<T: Codable>(
_ type: T.Type,
forKey key: String
) throws -> T? {
// Read from non-biometric Keychain with device passcode protection
guard let data = try fallbackKeychain.read(forKey: key) else {
return nil
}
return try JSONDecoder().decode(T.self, from: data)
}
private func fallbackToDevicePasscode<T: Codable>(
_ type: T.Type,
forKey key: String,
reason: String
) async throws -> T? {
let context = LAContext()
let _ = try await context.evaluatePolicy(
.deviceOwnerAuthentication, // Includes passcode fallback
localizedReason: reason
)
guard let data = try biometricKeychain.retrieve(
forKey: key,
context: context
) else {
return nil
}
return try JSONDecoder().decode(T.self, from: data)
}
}Conclusion
Biometric-protected storage is the standard for securing sensitive data in mobile banking apps. KlivvrStorageKit integrates Face ID and Touch ID through the Keychain's native access control mechanisms, ensuring that biometric authentication happens at the hardware level rather than in application code. The key principles are: use .biometryCurrentSet for financial data to invalidate items when biometrics change, always provide a passcode fallback path, and reuse authenticated LAContext instances within a session to avoid repeatedly prompting the user. Biometric security is only as strong as its weakest fallback, so design the entire authentication chain with care.
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.