Biometric authentication lets users verify identity with their fingerprint or face. On iOS, the LocalAuthentication framework mediates between your app and the Secure Enclave — a dedicated hardware coprocessor that isolates cryptographic operations from the main CPU and OS.
The system handles all data capture and matching. Your app never sees fingerprint data, face maps, or the mathematical representation stored in the Secure Enclave.
For Face ID, include NSFaceIDUsageDescription (Privacy — Face ID Usage Description) with a string explaining why your app needs biometrics:
<key>NSFaceIDUsageDescription</key>
<string>Unlock your account and authorize purchases.</string>The system shows this string when requesting Face ID access. Without this key, authorization requests may fail on Face ID devices.
Touch ID does not require a usage description key.
No special entitlement is needed for biometric authentication. Keychain biometric integration may require the Keychain Access Groups entitlement.
An LAContext brokers authentication between your app and the Secure Enclave.
let context = LAContext()A context begins in a fresh state. You can customize the UI before evaluation:
context.localizedCancelTitle = "Use Password"
context.localizedFallbackTitle = "Enter Passcode"localizedCancelTitle replaces the Cancel button text.localizedFallbackTitle replaces the fallback button title. Leave it as nil to use the system default, or set it to an empty string to hide the button.If you label the cancel button “Use Password”, route cancellation to your app’s password flow.
let context = LAContext()
var error: NSError?
guard context.canEvaluatePolicy(.deviceOwnerAuthentication, error: &error) else {
// Device authentication is unavailable. Offer an app-level fallback.
return
}
let reason = "Authenticate to access your account"
guard try await context.evaluatePolicy(
.deviceOwnerAuthentication,
localizedReason: reason
) else { return }
// Continue with the protected action.The evaluatePolicy call presents the system authentication UI, handles the interaction, and communicates with the Secure Enclave. It returns a Boolean result or throws an error. The async/await variant requires iOS 16+.
Call invalidate() when a context is no longer needed:
context.invalidate()This is useful for logout flows — the user should re-authenticate with a fresh context.
Do not reuse a context across different users or sessions. Create a new
LAContextper authentication attempt.
Use the biometric domain-state hash when you need to detect whether enrolled biometrics changed:
if #available(iOS 18.0, *) {
let stateHash = context.domainState.biometry.stateHash
}The hash is opaque, app-specific data. Compare it with a previously stored value to detect a change. It does not reveal which biometric changed, and it does not revoke access by itself. For secrets that must become inaccessible after enrollment changes, protect the Keychain item with .biometryCurrentSet.
Older code may use evaluatedPolicyDomainState. That property is deprecated; prefer domainState.biometry.stateHash on supported OS versions.
Use canEvaluatePolicy to check availability, then read biometryType to determine the hardware:
let context = LAContext()
var error: NSError?
guard context.canEvaluatePolicy(.deviceOwnerAuthenticationWithBiometrics, error: &error) else {
handleBiometryUnavailable(error)
return
}
switch context.biometryType {
case .touchID:
showFingerprintUI()
case .faceID:
showFaceUI()
case .none:
showPasswordUI()
@unknown default:
showGenericBiometryUI()
}Use .deviceOwnerAuthenticationWithBiometrics when checking whether biometrics are currently usable. The broader .deviceOwnerAuthentication policy can succeed when only the device passcode is available.
On visionOS, LocalAuthentication also supports Optic ID. Keep an @unknown default branch in iOS-focused code so new biometric types do not break your UI assumptions.
| Policy | Passcode fallback | Use case |
|---|---|---|
deviceOwnerAuthenticationWithBiometrics |
No | Flows that explicitly require biometrics |
deviceOwnerAuthentication |
Yes | General authentication, login |
With deviceOwnerAuthenticationWithBiometrics, the user must have enrolled biometrics. Choose it only when the product requirement explicitly excludes device-passcode fallback.
With deviceOwnerAuthentication, on biometrics failure the system prompts for the device passcode. This is the recommended default for most apps — the user escalates naturally from fingerprint or face to passcode to your own fallback.
The most common real-world use of biometrics is securing Keychain items. A biometry-protected item requires the user to authenticate before the Keychain returns the secret.
import Security
guard let accessControl = SecAccessControlCreateWithFlags(
nil,
kSecAttrAccessibleWhenPasscodeSetThisDeviceOnly,
.biometryCurrentSet,
nil
) else { return }
let query: [String: Any] = [
kSecClass as String: kSecClassGenericPassword,
kSecAttrAccount as String: "user_token",
kSecValueData as String: Data("secret-token".utf8),
kSecAttrAccessControl as String: accessControl,
]
let addStatus = SecItemAdd(query as CFDictionary, nil)
// Check addStatus == errSecSuccess in productionOn read, the system prompts for biometrics automatically:
let query: [String: Any] = [
kSecClass as String: kSecClassGenericPassword,
kSecAttrAccount as String: "user_token",
kSecReturnData as String: true,
kSecUseAuthenticationContext as String: LAContext(),
]
var result: AnyObject?
let readStatus = SecItemCopyMatching(query as CFDictionary, &result)
// Check readStatus == errSecSuccess in productionThe kSecUseAuthenticationContext accepts an LAContext, which lets you customize the prompt. Use .biometryCurrentSet for biometrics-only access, or .userPresence for biometrics + passcode fallback.
Prefer Keychain access control over unlocking an in-memory secret with
evaluatePolicyalone. The Keychain enforces authentication before returning the protected value. After retrieval, your app must still minimize the secret’s lifetime in memory.
Face ID behaves differently from Touch ID in two important ways:
evaluatePolicy. Touch ID waits for finger placement.Keep your surrounding UI consistent with biometryType, but let the system prompt guide the authentication interaction.
Handle expected LAError.Code cases and keep an @unknown default branch for new cases:
do {
let authenticated = try await context.evaluatePolicy(
.deviceOwnerAuthentication,
localizedReason: reason
)
guard authenticated else { return }
// Continue with the protected action.
} catch let error as LAError {
switch error.code {
case .authenticationFailed:
// Biometric mismatch or invalid passcode
case .userCancel:
// User tapped Cancel
return // silent
case .userFallback:
// User tapped fallback button
// Show password UI
case .biometryNotAvailable:
// No biometric hardware on device
case .biometryNotEnrolled:
// No fingerprints or face registered
case .biometryLockout:
// Too many failures. Biometrics locked.
// Evaluate with deviceOwnerAuthentication to offer passcode
case .passcodeNotSet:
// No passcode set on device
@unknown default:
break
}
}| Error | Cause | Recovery |
|---|---|---|
.authenticationFailed |
Biometric or passcode mismatch | Retry |
.userCancel |
User dismissed the dialog | No action needed |
.userFallback |
User tapped the fallback button | Show alternate auth |
.biometryNotAvailable |
Biometrics are unavailable | Offer another authentication method |
.biometryNotEnrolled |
No fingerprints or face registered | Prompt to enroll in Settings |
.biometryLockout |
Too many failed attempts | Escalate to passcode via deviceOwnerAuthentication |
.biometryNotPaired |
Required biometric accessory is not paired | Explain the required setup |
.biometryDisconnected |
Paired biometric accessory is disconnected | Ask the user to reconnect it |
.passcodeNotSet |
No device passcode | Prompt to set a passcode |
.invalidContext |
Context was already invalidated | Create a new LAContext |
In SwiftUI, keep the LAContext lifecycle decoupled from views. Use an @Observable service:
import LocalAuthentication
import Observation
@MainActor
@Observable
final class BiometricService {
private(set) var isAuthenticated = false
private(set) var biometryType: LABiometryType = .none
private(set) var error: String?
func checkAvailability() {
let context = LAContext()
var nsError: NSError?
guard context.canEvaluatePolicy(
.deviceOwnerAuthenticationWithBiometrics,
error: &nsError
) else {
error = nsError?.localizedDescription
return
}
biometryType = context.biometryType
}
func authenticate() async {
let context = LAContext()
let reason = "Authenticate to access your account"
do {
isAuthenticated = try await context.evaluatePolicy(
.deviceOwnerAuthentication,
localizedReason: reason
)
} catch {
self.error = error.localizedDescription
}
}
func logout() {
isAuthenticated = false
}
}struct ContentView: View {
@State private var bio = BiometricService()
var body: some View {
Group {
if bio.isAuthenticated {
SecretView()
} else {
VStack {
if bio.biometryType == .faceID {
Image(systemName: "faceid").font(.largeTitle)
}
Button("Authenticate") {
Task { await bio.authenticate() }
}
}
}
}
.onAppear { bio.checkAvailability() }
}
}Xcode provides a simulator Face ID menu: Simulator > Face ID > Enrolled / Not Enrolled. Toggle enrollment state to test different paths.
Do not bypass authentication for every Simulator build. For UI tests, inject a mock authenticator or gate a deterministic test double behind an explicit UI-test launch argument in Debug builds.
Abstract LAContext behind a protocol for testability:
protocol BiometryEvaluating {
func canEvaluatePolicy(_ policy: LAPolicy, error: NSErrorPointer) -> Bool
func evaluatePolicy(_ policy: LAPolicy, localizedReason: String) async throws -> Bool
var biometryType: LABiometryType { get }
}
extension LAContext: BiometryEvaluating {}Then inject a mock that returns .touchID and throws .authenticationFailed on demand.
Current SDKs also expose APIs such as LARight, LARightStore, LAEnvironment, and LocalAuthenticationView. They support richer authorization and persisted-secret workflows on newer OS versions. LAContext remains the practical baseline when your app supports older iOS releases or uses existing Keychain access-control flows.
canEvaluatePolicy to check availability, but still handle evaluation failures. System state can change between calls.deviceOwnerAuthentication by default. It provides the passcode escalation path.localizedCancelTitle to something actionable (“Use Password”) rather than just “Cancel.”LAContext per authentication attempt. Reusing an invalidated context or one from a previous session will cause errors.biometryType to adapt UI — show matching icons and text without assuming every device uses Face ID or Touch ID..biometryCurrentSet rather than evaluatePolicy alone for defense in depth.