LocalAuthentication: Adding Face ID and Touch ID to Your App
Users expect sensitive screens — financial dashboards, saved passwords, private notes — to be gated behind Face ID or Touch ID. The good news: Apple’s LocalAuthentication framework makes biometric authentication a single async call. The bad news: most implementations get the fallback flow, error handling, and Keychain integration wrong, leaving either a broken experience or a false sense of security.
This post covers LAContext end to end: checking biometric availability, evaluating policies, handling every error
case, building proper fallback flows, and integrating with the Keychain for biometric-protected credential storage. We
won’t cover Passkeys or ASAuthorization — those are in
Passkeys and AuthenticationServices.
Contents
- The Problem
- Understanding LAContext and Policies
- Implementing Biometric Authentication
- Handling Errors and Fallbacks
- Keychain Integration with Biometric Protection
- Advanced Usage
- When to Use (and When Not To)
- Summary
The Problem
Picture the Incredibles’ family vault — a secure room in their house where they store super-suit prototypes, mission dossiers, and Edna Mode’s confidential designs. Bob Parr wants to unlock the vault with Face ID, but if biometrics fail (maybe he is wearing his mask), he needs a passcode fallback. And the most sensitive items — Edna’s design files — should only be accessible when biometric authentication succeeds, not with a passcode alone.
Here is what a naive biometric check looks like:
import LocalAuthentication
func unlockVault() {
let context = LAContext()
context.evaluatePolicy(
.deviceOwnerAuthenticationWithBiometrics,
localizedReason: "Access the Incredibles Vault"
) { success, error in
if success {
// Show vault contents
}
}
}
This looks simple, but it has several problems. It does not check whether biometrics are available before presenting the
prompt. It ignores the error entirely, so users get no feedback when authentication fails. It uses
.deviceOwnerAuthenticationWithBiometrics which has no fallback — if Face ID fails three times, the user is locked out
with no recourse. And most critically, it only gates the UI. The underlying data is still accessible to anyone who can
read the app’s sandbox. Real security requires tying the data itself to biometric authentication through the Keychain.
Understanding LAContext and Policies
LAContext is the entry point for all
biometric operations. Each context instance represents a single authentication session. Two methods matter:
canEvaluatePolicy(_:error:)— Checks whether a specific authentication policy is available on the current device. Call this before attempting authentication.evaluatePolicy(_:localizedReason:)— Presents the biometric prompt and returns the result.
The Two Policies
| Policy | Biometrics | Passcode Fallback | Use Case |
|---|---|---|---|
.deviceOwnerAuthenticationWithBiometrics | Required | No fallback | When you need biometric-only access (e.g., confirming identity for a financial transaction). |
.deviceOwnerAuthentication | Preferred | Falls back to device passcode | When you need authentication but don’t care whether it’s biometric or passcode. |
The choice between these policies is an architectural decision, not a UI preference. If your security model requires
biometric proof (the user physically present), use .deviceOwnerAuthenticationWithBiometrics. If you just need to
confirm the device owner is present, use .deviceOwnerAuthentication — it provides a better user experience because
passcode is always available as a fallback.
Apple Docs:
LAPolicy— LocalAuthentication
Implementing Biometric Authentication
Here is a production-grade authentication service for the Incredibles Vault. It checks availability first, uses the async/await variant introduced in iOS 16, and surfaces meaningful results.
import LocalAuthentication
@Observable
final class BiometricAuthService {
private(set) var biometricType: LABiometryType = .none
private(set) var isAuthenticated = false
enum AuthResult {
case success
case biometricsUnavailable(reason: String)
case failed(LAError)
case cancelled
}
/// Checks what biometric hardware is available.
func checkBiometricAvailability() -> LABiometryType {
let context = LAContext()
var error: NSError?
let canEvaluate = context.canEvaluatePolicy(
.deviceOwnerAuthenticationWithBiometrics,
error: &error
)
if canEvaluate {
biometricType = context.biometryType
} else {
biometricType = .none
}
return biometricType
}
/// Authenticates the user with biometrics, falling back to passcode.
func authenticate(reason: String) async -> AuthResult {
let context = LAContext()
context.localizedCancelTitle = "Use Password"
context.localizedFallbackTitle = "Enter Passcode"
// Check availability first
var error: NSError?
guard context.canEvaluatePolicy(
.deviceOwnerAuthentication,
error: &error
) else {
let message = error?.localizedDescription ?? "Biometrics unavailable"
return .biometricsUnavailable(reason: message)
}
do {
let success = try await context.evaluatePolicy(
.deviceOwnerAuthentication,
localizedReason: reason
)
if success {
isAuthenticated = true
return .success
} else {
return .failed(LAError(.authenticationFailed))
}
} catch let laError as LAError {
if laError.code == .userCancel || laError.code == .appCancel {
return .cancelled
}
return .failed(laError)
} catch {
return .failed(LAError(.authenticationFailed))
}
}
/// Resets the authentication state — call when locking the vault.
func deauthenticate() {
isAuthenticated = false
}
}
A few important details. First, create a new LAContext for each authentication attempt. Reusing a context after a
successful authentication skips the biometric prompt — the context caches the result. This is useful in some scenarios
(see Advanced Usage below), but dangerous if you want to re-verify the user’s identity.
Second, localizedReason is the string shown below the Face ID or Touch ID prompt. Apple requires this to explain why
authentication is needed. Vague strings like “Authenticate” will be rejected during App Review. Use something specific:
“Access the Incredibles Vault” or “Authorize transfer of $500.”
SwiftUI Integration
Wire the service into a view that gates access to sensitive content:
struct VaultView: View {
@State private var authService = BiometricAuthService()
@State private var authError: String?
var body: some View {
Group {
if authService.isAuthenticated {
VaultContentsView()
} else {
VaultLockedView(
biometricType: authService.biometricType,
errorMessage: authError,
onUnlock: unlock
)
}
}
.onAppear {
_ = authService.checkBiometricAvailability()
}
}
private func unlock() {
Task {
let result = await authService.authenticate(
reason: "Unlock the Incredibles Vault to view classified missions"
)
switch result {
case .success:
authError = nil
case .biometricsUnavailable(let reason):
authError = reason
case .failed(let error):
authError = describeError(error)
case .cancelled:
authError = nil // User chose to cancel, no error to show
}
}
}
private func describeError(_ error: LAError) -> String {
switch error.code {
case .biometryLockout:
return "Too many failed attempts. Use your device passcode to re-enable Face ID."
case .biometryNotEnrolled:
return "No biometric data enrolled. Go to Settings > Face ID & Passcode."
default:
return "Authentication failed. Please try again."
}
}
}
Tip: Add
NSFaceIDUsageDescriptionto yourInfo.plistwith a string explaining why your app uses Face ID. This is required on devices with Face ID and distinct from thelocalizedReasonparameter — theInfo.pliststring appears in the system permissions dialog the first time Face ID is used, whilelocalizedReasonappears on every authentication prompt.
Handling Errors and Fallbacks
LAError has many codes, and handling them correctly is what separates a polished app from a frustrating one. Here is
the complete error taxonomy:
extension LAError.Code {
var userFacingDescription: String {
switch self {
case .authenticationFailed:
// Biometric did not match. The system shows its own
// "Try Again" prompt, so you rarely need to handle this.
return "Authentication failed."
case .userCancel:
// User tapped Cancel on the biometric prompt.
return "Authentication was cancelled."
case .userFallback:
// User tapped the fallback button (e.g., "Enter Password").
// Present your own password entry UI.
return "User chose to enter a password."
case .systemCancel:
// System cancelled (e.g., another app came to foreground).
return "Authentication interrupted by the system."
case .passcodeNotSet:
// No device passcode is configured.
return "Please set a device passcode in Settings."
case .biometryNotAvailable:
// Hardware not present or disabled by MDM/restrictions.
return "Biometric authentication is not available."
case .biometryNotEnrolled:
// Hardware present but no face/fingerprint enrolled.
return "No biometric data enrolled. Set up Face ID or Touch ID in Settings."
case .biometryLockout:
// Too many failed attempts. Requires passcode to re-enable.
return "Biometrics locked. Enter your device passcode to continue."
default:
return "An unknown authentication error occurred."
}
}
}
The .userFallback code deserves special attention. It fires when the user taps the fallback button on the biometric
prompt — the one you labeled with localizedFallbackTitle. When this fires, you should present your own custom password
entry UI, not rely on the system. If you are using .deviceOwnerAuthentication, the system handles the passcode
fallback automatically, and .userFallback is never emitted.
Warning:
.biometryLockoutmeans the user has failed biometric authentication too many times (5 consecutive failures for Face ID, 3 for Touch ID). The only way to reset the lockout is a successful device passcode entry. If you are using.deviceOwnerAuthenticationWithBiometrics, your app cannot recover from this state. Switch to.deviceOwnerAuthenticationif you want the system passcode to serve as a recovery path.
Keychain Integration with Biometric Protection
The BiometricAuthService above gates the UI, but it does not protect the data. If someone accesses the app’s sandbox
(through a backup, jailbreak, or device exploit), the data is still readable. For real security, store sensitive
credentials in the Keychain with biometric access control.
The Keychain’s SecAccessControl lets you
require biometric authentication before a Keychain item can be read. The item is encrypted with a key that the Secure
Enclave only releases after biometric verification.
import Security
struct BiometricKeychain {
enum KeychainError: Error {
case saveFailed(OSStatus)
case readFailed(OSStatus)
case deleteFailed(OSStatus)
case accessControlCreationFailed
case unexpectedData
}
/// Saves Edna Mode's design credentials, protected by biometrics.
static func save(
credentials: Data,
forAccount account: String,
service: String = "com.incredibles.vault"
) throws {
// Create access control requiring biometrics
guard let accessControl = SecAccessControlCreateWithFlags(
nil,
kSecAttrAccessibleWhenPasscodeSetThisDeviceOnly,
.biometryCurrentSet,
nil
) else {
throw KeychainError.accessControlCreationFailed
}
// Build the query
let query: [String: Any] = [
kSecClass as String: kSecClassGenericPassword,
kSecAttrService as String: service,
kSecAttrAccount as String: account,
kSecValueData as String: credentials,
kSecAttrAccessControl as String: accessControl,
kSecUseAuthenticationContext as String: LAContext()
]
// Delete existing item if present
SecItemDelete(query as CFDictionary)
let status = SecItemAdd(query as CFDictionary, nil)
guard status == errSecSuccess else {
throw KeychainError.saveFailed(status)
}
}
/// Reads credentials — triggers biometric prompt automatically.
static func read(
account: String,
service: String = "com.incredibles.vault",
reason: String = "Access Edna Mode's design credentials"
) throws -> Data {
let context = LAContext()
context.localizedReason = reason
let query: [String: Any] = [
kSecClass as String: kSecClassGenericPassword,
kSecAttrService as String: service,
kSecAttrAccount as String: account,
kSecReturnData as String: true,
kSecMatchLimit as String: kSecMatchLimitOne,
kSecUseAuthenticationContext as String: context
]
var result: AnyObject?
let status = SecItemCopyMatching(query as CFDictionary, &result)
guard status == errSecSuccess, let data = result as? Data else {
throw KeychainError.readFailed(status)
}
return data
}
/// Removes credentials from the Keychain.
static func delete(
account: String,
service: String = "com.incredibles.vault"
) throws {
let query: [String: Any] = [
kSecClass as String: kSecClassGenericPassword,
kSecAttrService as String: service,
kSecAttrAccount as String: account,
]
let status = SecItemDelete(query as CFDictionary)
guard status == errSecSuccess || status == errSecItemNotFound else {
throw KeychainError.deleteFailed(status)
}
}
}
The critical flag is .biometryCurrentSet. This means:
- The item is only accessible after biometric authentication.
- If the user enrolls a new fingerprint or resets Face ID, the item becomes inaccessible (you will get
errSecAuthFailed). This is a security feature: it prevents someone from adding their own biometrics to a stolen device and then accessing existing credentials.
Alternative flags include .biometryAny (survives biometric enrollment changes — less secure but more convenient) and
.userPresence (allows either biometrics or passcode).
Apple Docs:
SecAccessControlCreateWithFlags— Security
Advanced Usage
Reusing Authentication Context
An LAContext caches a successful authentication for a configurable duration. This is useful when Dash Parr needs to
perform multiple secure operations in quick succession without being prompted every time:
func performMultipleSecureOperations() async {
let context = LAContext()
// Authentication result stays valid for 30 seconds
context.touchIDAuthenticationAllowableReuseDuration = 30
do {
try await context.evaluatePolicy(
.deviceOwnerAuthenticationWithBiometrics,
localizedReason: "Access Dash's speed training records"
)
// All of these use the cached authentication — no re-prompt
let records = try BiometricKeychain.read(
account: "dash-training-records"
)
let missions = try BiometricKeychain.read(
account: "dash-missions"
)
// Process records and missions...
} catch {
// Handle error
}
}
Warning: Setting
touchIDAuthenticationAllowableReuseDurationtoo high (e.g., 300 seconds) weakens your security posture. Someone could authenticate, put the phone down, and another person could access the data within the reuse window. Apple caps this atLATouchIDAuthenticationMaximumAllowableReuseDuration(5 minutes). For financial operations, keep it at 0 (the default) to require fresh authentication every time.
Invalidating Context on Biometric Changes
If you need to detect when biometrics change (e.g., a new face is enrolled), compare the evaluatedPolicyDomainState
property across sessions:
final class BiometricIntegrityChecker {
private let domainStateKey = "com.incredibles.vault.biometricDomainState"
/// Returns true if biometric enrollment has changed since the last check.
func hasBiometricEnrollmentChanged() -> Bool {
let context = LAContext()
var error: NSError?
guard context.canEvaluatePolicy(
.deviceOwnerAuthenticationWithBiometrics,
error: &error
) else {
return true // Assume changed if we can't evaluate
}
guard let currentState = context.evaluatedPolicyDomainState else {
return true
}
let storedState = UserDefaults.standard.data(forKey: domainStateKey)
if storedState == nil {
// First run — store the current state
UserDefaults.standard.set(currentState, forKey: domainStateKey)
return false
}
return currentState != storedState
}
/// Updates the stored domain state after user confirms their identity.
func updateStoredDomainState() {
let context = LAContext()
var error: NSError?
guard context.canEvaluatePolicy(
.deviceOwnerAuthenticationWithBiometrics,
error: &error
),
let state = context.evaluatedPolicyDomainState else {
return
}
UserDefaults.standard.set(state, forKey: domainStateKey)
}
}
This is useful for high-security apps (banking, password managers) where you want to force re-authentication or re-enrollment when biometric data changes. Violet Parr enrolling a new face should trigger a verification flow before she can access the family vault with the updated biometrics.
When to Use (and When Not To)
| Scenario | Recommendation |
|---|---|
| Gating access to a sensitive screen (private notes, health data) | Use LAContext with .deviceOwnerAuthentication for biometrics with passcode fallback. |
| Protecting stored credentials (API tokens, passwords) | Store in Keychain with .biometryCurrentSet access control — the OS handles the prompt. |
| Confirming identity before a financial transaction | Use .deviceOwnerAuthenticationWithBiometrics (no passcode fallback) for stronger proof of presence. |
| App-level lock screen on every foreground entry | Use LAContext in sceneDidBecomeActive — but keep touchIDAuthenticationAllowableReuseDuration short. |
| Replacing a login system entirely | Do not. LocalAuthentication verifies the device owner, not your user’s identity. Pair it with server-side authentication or Passkeys. |
| Protecting data at rest from forensic extraction | Keychain with .biometryCurrentSet plus kSecAttrAccessibleWhenPasscodeSetThisDeviceOnly. Data is unrecoverable without the passcode. |
The key distinction: LAContext.evaluatePolicy is a local check. It verifies that the person holding the device is
the device owner. It does not authenticate the user against your backend, and it does not create any token or credential
you can send to a server. For server-verified identity, combine LocalAuthentication with a server challenge or move to
Passkeys.
Summary
- Use
.deviceOwnerAuthenticationfor biometrics with automatic passcode fallback — best user experience for most apps. - Use
.deviceOwnerAuthenticationWithBiometricsonly when you need biometric-specific proof of presence (financial confirmations). - Always call
canEvaluatePolicybeforeevaluatePolicyand handle everyLAError.Code— especially.biometryLockoutand.userFallback. - For real data protection, store credentials in the Keychain with
SecAccessControland.biometryCurrentSet— this ties decryption to the Secure Enclave, not just a UI gate. - Create a fresh
LAContextfor each authentication unless you explicitly want cached results viatouchIDAuthenticationAllowableReuseDuration.
For the next generation of passwordless authentication, see Passkeys and AuthenticationServices. For a broader look at securing your app’s data and transport, check out App Security.