Developing Face ID Biometric Authorization in iOS App
Face ID in iOS works through Local Authentication framework — specifically through LAContext and evaluatePolicy(_:localizedReason:reply:) method. Sounds simple until you tackle error handling, fallback scenarios, and behavior on devices without Face ID.
On App Store review, apps with incorrect biometrics get rejected under guideline 5.1.1 (Privacy) — if reason string doesn't explain to user why access is needed, or if .biometryNotAvailable leads to crash loop instead of graceful degradation.
Common mistakes
The most frequent issue — calling evaluatePolicy on main thread without checking canEvaluatePolicy. App hangs 0.5–1 second when initializing LAContext if device just locked. On iPhone 14 Pro unnoticeable, on iPhone SE 2nd gen — noticeable.
Second issue — incorrect LAError handling. Error has five states requiring different UX: .userCancel, .userFallback, .systemCancel, .biometryLockout, .biometryNotAvailable. Developers often dump everything in one catch and show generic "authorization error". User after three failed Face ID attempts gets lockout — biometrics blocked until passcode entry. App must handle this and offer fallback, otherwise user simply gets stuck.
Third — storing tokens after successful biometrics. Often see access token put in UserDefaults. Correct — Keychain with kSecAttrAccessControl attribute created via SecAccessControlCreateWithFlags with .biometryCurrentSet or .userPresence flag. On biometric change (adding new finger, re-registering face) .biometryCurrentSet invalidates entry automatically.
How we build implementation
Work with LAContext using .deviceOwnerAuthenticationWithBiometrics policy for pure biometrics or .deviceOwnerAuthentication if passcode fallback needed.
Basic flow:
- Check
canEvaluatePolicy— get biometric type viacontext.biometryType(.faceID,.touchID,.opticIDon Vision Pro). - Run
evaluatePolicyon background thread (GCD or async/await withTask.detached). - In
replyblock handle allLAErrorvariants — each in separate case. - On success retrieve token from Keychain via
SecItemCopyMatching.
For Swift Concurrency stack wrap LAContext in withCheckedThrowingContinuation. Important: LAContext is not Sendable, so with async/await either keep it on MainActor, or use @unchecked Sendable with explicit synchronization.
Keychain record with biometric protection:
let access = SecAccessControlCreateWithFlags(
nil,
kSecAttrAccessibleWhenPasscodeSetThisDeviceOnly,
.biometryCurrentSet,
nil
)
Flag kSecAttrAccessibleWhenPasscodeSetThisDeviceOnly guarantees data won't be included in iCloud Backup and won't restore on another device.
Testing
Simulator supports Face ID — via Features menu > Face ID you can emulate success and error. But .biometryLockout on simulator cannot be reproduced — test only on physical devices. For UI tests use protocol wrapper over LAContext, which substitute with mock in XCTest.
Integration with app architecture
In VIPER and Clean Architecture, biometric module goes to separate Interactor (BiometricAuthInteractor) with dependency through BiometricServiceProtocol protocol. In SwiftUI + MVVM — as @MainActor class publishing @Published var authState: AuthState.
Support scenarios: first biometric registration (user hasn't enabled Face ID in app settings yet), switch to PIN code, complete biometric disable. All states persist in UserDefaults as boolean flag isBiometricEnabled — not token itself, only metadata about user choice.
Work stages
Audit of current auth module (if exists) → scenario design (happy path + all error cases) → service layer development with unit tests → UI integration → QA on real devices (iPhone SE, iPhone 15 Pro, iPad with Face ID) → review before App Store submission.
Implementation from scratch timeframe — 3 to 7 business days depending on complexity of existing architecture and number of app entry points.







