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.

technical6 min readBy Klivvr Engineering
Share:

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

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
technical

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.

8 min read