Biometric authentication has become table stakes in iOS apps. Locking the app with Face ID or Touch ID, confirming sensitive operations, using biometric-backed keychain. The framework: LocalAuthentication.
I’ve integrated biometrics in five shipped apps. A banking app, a finance tracker, a medical records app, and two privacy-sensitive productivity apps. Each one needed a slightly different pattern.
The basic API
import LocalAuthentication
let context = LAContext()
var error: NSError?
guard context.canEvaluatePolicy(.deviceOwnerAuthenticationWithBiometrics, error: &error) else {
print("Biometric not available: (error?.localizedDescription ?? "")")
return
}
context.evaluatePolicy(.deviceOwnerAuthenticationWithBiometrics, localizedReason: "To access the app") { success, error in
DispatchQueue.main.async {
if success {
// Auth success
} else {
// Auth failed
}
}
}Four lines. But the correct usage is all in the details.
Choosing the policy
Two main policies:
.deviceOwnerAuthenticationWithBiometrics: Face ID or Touch ID only. If biometric fails, the user has nothing else to try.
.deviceOwnerAuthentication: biometric with fallback to passcode. If biometric fails, the device passcode prompt appears.
I usually prefer .deviceOwnerAuthentication. When Face ID refuses in certain conditions (mask, dim room, sunglasses), the user can continue with passcode. Less friction.
Exception: for certain financial operation confirmations, “biometric only” discipline can fit. Passcode carries a somebody-else-knows risk.
localizedReason: what you’re telling the user
The text that shows in the Face ID prompt. It needs to be specific and action-oriented.
Bad: “Authentication”
Good: “Use Face ID to access your financial data”
The user should be able to answer “why is it asking for biometric?” in one second. Vague copy breeds mistrust.
Localized version:
context.evaluatePolicy(
.deviceOwnerAuthenticationWithBiometrics,
localizedReason: NSLocalizedString(
"biometric.reason.vault",
value: "Unlock the vault",
comment: ""
)
) { ... }Biometry type detection
Does the device have Face ID or Touch ID? You need to know to show the right copy:
let context = LAContext()
var error: NSError?
context.canEvaluatePolicy(.deviceOwnerAuthenticationWithBiometrics, error: &error)
switch context.biometryType {
case .faceID:
buttonTitle = "Sign in with Face ID"
case .touchID:
buttonTitle = "Sign in with Touch ID"
case .opticID:
buttonTitle = "Sign in with Optic ID" // Vision Pro
case .none:
buttonTitle = "Sign in with passcode"
@unknown default:
buttonTitle = "Secure sign in"
}Call canEvaluatePolicy before checking biometryType, otherwise the type comes back as .none.
Icon choice: use SF Symbols faceid or touchid. Seeing the icon tells the user what’s about to happen.
Info.plist permission
Since iOS 11.3, Face ID requires a permission string. In Info.plist:
<key>NSFaceIDUsageDescription</key>
<string>Sign in to your app quickly and securely with Face ID.</string>Without that string, the Face ID prompt never shows, and the app crashes in review. Touch ID doesn’t have a separate permission.
LAError handling
Error types that can come back during evaluation:
context.evaluatePolicy(...) { success, error in
if let error = error as? LAError {
switch error.code {
case .userCancel:
// User hit Cancel, move on silently
case .userFallback:
// User tapped "Use Passcode", route to the passcode flow
case .biometryNotAvailable:
// No biometric on device or it's off
showError("Face ID is not available")
case .biometryNotEnrolled:
// Biometric exists but nothing enrolled
showError("Face ID isn't set up. Add it in Settings.")
case .biometryLockout:
// 5 failed attempts, passcode required
showError("Face ID is temporarily locked. Passcode required.")
case .passcodeNotSet:
// No device passcode set
showError("No device passcode is set.")
case .systemCancel:
// System canceled the call (incoming call, etc.)
case .invalidContext:
// Context invalid
default:
showError("Could not authenticate.")
}
}
}A different user message for each error type. A generic “auth failed” frustrates the user.
Biometric-backed Keychain
The real power of biometric auth is protecting the Keychain with it. The user’s password, token, or secret key is only readable after biometric auth.
let access = SecAccessControlCreateWithFlags(
nil,
kSecAttrAccessibleWhenUnlockedThisDeviceOnly,
.biometryCurrentSet,
nil
)
let query: [String: Any] = [
kSecClass as String: kSecClassGenericPassword,
kSecAttrService as String: "com.example.vault",
kSecAttrAccount as String: "user_token",
kSecValueData as String: token.data(using: .utf8)!,
kSecAttrAccessControl as String: access
]
SecItemAdd(query as CFDictionary, nil)The .biometryCurrentSet flag: “only the currently enrolled biometric set can read this”. If the user adds a new finger or face in Settings, the old Keychain item is invalidated.
The .biometryAny alternative: any biometric match (current or added later). For security-sensitive apps, .biometryCurrentSet is stricter.
Retry policy
What do you do when biometric fails? Don’t auto-retry. Let the user tap a button to try again.
Biometric auth failed
-> Show "Try again" button
-> User tap triggers evaluatePolicy againAutomatic retry spams the Face ID dialog and feels aggressive.
When the app goes to background
You often want to re-lock with biometrics when the app backgrounds. Common pattern:
func applicationWillResignActive(_ application: UIApplication) {
lockStartTime = Date()
}
func applicationDidBecomeActive(_ application: UIApplication) {
if let startTime = lockStartTime,
Date().timeIntervalSince(startTime) > 60 {
// Backgrounded for more than 60 seconds, lock required
showBiometricLock()
}
lockStartTime = nil
}If the user hops to another app and comes back 10 seconds later, re-unlocking is unnecessary. 60 seconds is a good threshold. 30 seconds for sensitive apps, lock on every background for ultra-sensitive ones.
Two-factor: biometric plus something
Biometric alone isn’t enough in every case. For high-value operations, adding a second factor is the security best practice.
Pattern:
- Low risk: biometric is enough
- Medium risk: biometric plus PIN
- High risk: biometric plus SMS OTP or time-based OTP
Biometric isn’t immune to replay attacks (bypassing Face ID with a photo is rare but possible). Don’t trust biometric alone for high-value operations.
Testing discipline
The simulator can fake Face ID (Features -> Face ID -> Matching Face / Non-matching Face). Touch ID can be faked too.
Good for fast iteration. Final testing has to happen on real devices. Edge cases (masked Face ID, wet finger Touch ID) can’t be emulated in the simulator.
Let the user opt in
Offering biometric as opt-in is better UX than forcing it. On first launch, “Would you like to use Face ID to sign in?” and if yes, save biometric-backed to Keychain.
Users who decline stay on the normal password flow. Users who opt in take the extra friction, but it was their choice.
Performance
Face ID takes around 1 second, Touch ID around 0.5. The UI needs to be ready in that window. After a successful biometric prompt, transition instantly. On failure, fall back UI.
Triggering the biometric prompt during the splash screen means the user is already waiting, so it becomes a single wait. Showing a separate lock screen and then prompting for biometrics creates two waits stacked on each other.
Closing advice
Biometric auth is a security feature but also a UX feature. Done right, users love it. Done wrong, it adds friction.
Principles:
- Specific reason string
- User messages matched to error types
- Biometric-backed Keychain for sensitive data
- Balanced background lock timing
- Opt-in by default
- Combine with 2FA for high risk
Apply these six and biometric auth becomes a premium part of the app’s security and experience.