Opening
Biometric authentication has become the standard user expectation on modern iOS devices, and developers who skip it risk friction during onboarding and login flows. This guide covers Face ID and Touch ID: a Swift & iOS guide for developers, walking you through the LocalAuthentication framework with concrete patterns we use in production e-commerce and SaaS applications.
Why biometric authentication matters
Touch ID arrived in 2013, Face ID in 2017, and today users are frustrated when apps force them to type passwords. Beyond UX, biometrics reduce support tickets from forgotten passwords and lower fraud exposure by keeping credentials off the network. We've seen conversion lift of 3–5% just by adding frictionless biometric unlock to checkout flows.
The LocalAuthentication framework is Apple's standard. It abstracts the device hardware and gives your app a clean API for evaluating biometric policy without ever touching the actual fingerprint or facial data—Apple keeps that locked in the Secure Enclave.
LAContext and evaluatePolicy
The entry point is LAContext. You create one, call evaluatePolicy, and handle the result. Here's the foundation:
import LocalAuthentication
func authenticateWithBiometric(completion: @escaping (Result<Void, Error>) -> Void) {
let context = LAContext()
var error: NSError?
// Check if biometry is available on this device
guard context.canEvaluatePolicy(.deviceOwnerAuthenticationWithBiometrics, error: &error) else {
completion(.failure(error ?? NSError(domain: "BiometricError", code: -1)))
return
}
// Attempt biometric unlock
context.evaluatePolicy(
.deviceOwnerAuthenticationWithBiometrics,
localizedReason: "Unlock your account to continue"
) { success, error in
DispatchQueue.main.async {
if success {
completion(.success(()))
} else {
completion(.failure(error ?? NSError(domain: "BiometricError", code: -1)))
}
}
}
}
The canEvaluatePolicy check is crucial—it tells you whether the device has biometry enabled and whether the user has enrolled. On a simulator without biometry or on a device where Face ID is disabled in Settings, this returns false. Always check before calling evaluatePolicy, or your app will hang waiting for a response that never comes.
The localizedReason string appears on the biometric prompt. Keep it short and clear. We've seen users cancel because they didn't understand why we were asking for their face or fingerprint.
In production, we wrap this in a utility:
enum BiometricAuthError: LocalizedError {
case notAvailable
case userCancelled
case userFallback
case biometryLockedOut
case other(Error)
var errorDescription: String? {
switch self {
case .notAvailable:
return "Face ID or Touch ID is not available on this device"
case .userCancelled:
return "Authentication was cancelled"
case .userFallback:
return "User selected passcode fallback"
case .biometryLockedOut:
return "Too many failed attempts. Use passcode."
case .other(let error):
return error.localizedDescription
}
}
}
class BiometricAuthenticator {
static let shared = BiometricAuthenticator()
func authenticate(reason: String, completion: @escaping (Result<Void, BiometricAuthError>) -> Void) {
let context = LAContext()
context.localizedFallbackTitle = "Use Passcode"
var error: NSError?
guard context.canEvaluatePolicy(.deviceOwnerAuthenticationWithBiometrics, error: &error) else {
if let nsError = error as NSError? {
if nsError.code == LAError.biometryLockedOut.rawValue {
completion(.failure(.biometryLockedOut))
return
}
}
completion(.failure(.notAvailable))
return
}
context.evaluatePolicy(.deviceOwnerAuthenticationWithBiometrics, localizedReason: reason) { success, error in
DispatchQueue.main.async {
if success {
completion(.success(()))
} else if let laError = error as? LAError {
switch laError.code {
case .userCancel:
completion(.failure(.userCancelled))
case .userFallback:
completion(.failure(.userFallback))
case .biometryLockedOut:
completion(.failure(.biometryLockedOut))
default:
completion(.failure(.other(laError)))
}
} else {
completion(.failure(.other(error ?? NSError(domain: "Unknown", code: -1))))
}
}
}
}
}
NSFaceIDUsageDescription in Info.plist
iOS requires you to declare your intent to use Face ID in the app's Info.plist. Without it, your app will crash when you call evaluatePolicy. This is Apple's privacy gate.
Add both of these keys:
<key>NSFaceIDUsageDescription</key>
<string>We use Face ID to securely unlock your account</string>
<key>NSBiometryUsageDescription</key>
<string>We use your fingerprint or face to securely unlock your account</string>
NSFaceIDUsageDescription is for Face ID on iPhone X and later. NSBiometryUsageDescription covers both Face ID and Touch ID generically and is the preferred key for new apps. The string you provide appears in the permission prompt the first time the user attempts biometric authentication.
In Xcode, you can edit this in the Info tab under your target, or edit Info.plist directly. We recommend the source code editor to avoid conflicts when merging with teammates.
| Key | Purpose | Required |
|---|---|---|
| NSBiometryUsageDescription | Generic biometric usage (Face ID or Touch ID) | Yes, for new apps |
| NSFaceIDUsageDescription | Face ID specific (legacy, still checked on older OS) | Recommended for compatibility |
Fallback to passcode
Not every user has biometry enrolled, and biometry can fail—lockouts happen, environmental issues arise. You must always provide a passcode fallback. The LAContext.localizedFallbackTitle property controls the text on the fallback button. Set it to guide the user:
func authenticateWithFallback(completion: @escaping (Result<Void, Error>) -> Void) {
let context = LAContext()
context.localizedFallbackTitle = "Use Account Password"
var error: NSError?
// If biometry is not available, allow passcode via deviceOwnerAuthentication
if !context.canEvaluatePolicy(.deviceOwnerAuthenticationWithBiometrics, error: &error) {
let passcodeContext = LAContext()
passcodeContext.evaluatePolicy(
.deviceOwnerAuthentication,
localizedReason: "Unlock with your device passcode"
) { success, _ in
DispatchQueue.main.async {
completion(success ? .success(()) : .failure(NSError(domain: "PasscodeError", code: -1)))
}
}
return
}
// Try biometric first
context.evaluatePolicy(.deviceOwnerAuthenticationWithBiometrics, localizedReason: "Unlock your account") { success, error in
DispatchQueue.main.async {
if success {
completion(.success(()))
} else if let laError = error as? LAError, laError.code == .userFallback {
// User tapped the fallback button; now prompt for passcode
let passcodeContext = LAContext()
passcodeContext.evaluatePolicy(
.deviceOwnerAuthentication,
localizedReason: "Unlock with your device passcode"
) { passcodeSuccess, _ in
DispatchQueue.main.async {
completion(passcodeSuccess ? .success(()) : .failure(NSError(domain: "PasscodeError", code: -1)))
}
}
} else {
completion(.failure(error ?? NSError(domain: "BiometricError", code: -1)))
}
}
}
}
There are two policies: .deviceOwnerAuthenticationWithBiometrics (biometric only) and .deviceOwnerAuthentication (biometric + passcode). We use biometric-only first for a smooth flow, then fall back to the full authentication prompt if the user chooses passcode or if biometry is locked out.
Protect Keychain items with biometry
Biometric unlock is most powerful when combined with Keychain. You can store tokens, credentials, and secrets in Keychain and require biometric or passcode authentication to access them. This ensures even if someone gains physical access to the device, they can't extract your credentials without passing biometric gates.
Use SecAccessControl to bind a Keychain item to biometry:
import Security
func storeTokenInKeychain(token: String, account: String) throws {
let query: [String: Any] = [
kSecClass as String: kSecClassGenericPassword,
kSecAttrAccount as String: account,
kSecAttrService as String: "com.yourapp.auth",
kSecValueData as String: token.data(using: .utf8)!,
kSecAttrAccessible as String: kSecAttrAccessibleWhenPasscodeSetThisDeviceOnly
]
// Create access control requiring biometry
var error: Unmanaged<CFError>?
guard let accessControl = SecAccessControlCreateWithFlags(
kCFAllocatorDefault,
kSecAttrAccessibleWhenPasscodeSetThisDeviceOnly,
.biometryCurrentSet,
&error
) else {
throw error?.takeRetainedValue() as? Error ?? NSError(domain: "KeychainError", code: -1)
}
var keychainQuery: [String: Any] = [
kSecClass as String: kSecClassGenericPassword,
kSecAttrAccount as String: account,
kSecAttrService as String: "com.yourapp.auth",
kSecValueData as String: token.data(using: .utf8)!,
kSecAttrAccessControl as String: accessControl
]
// Delete existing item if present
SecItemDelete(keychainQuery as CFDictionary)
// Add the new item
let status = SecItemAdd(keychainQuery as CFDictionary, nil)
guard status == errSecSuccess else {
throw NSError(domain: "KeychainError", code: Int(status))
}
}
func retrieveTokenFromKeychain(account: String) throws -> String {
let query: [String: Any] = [
kSecClass as String: kSecClassGenericPassword,
kSecAttrAccount as String: account,
kSecAttrService as String: "com.yourapp.auth",
kSecReturnData as String: true,
kSecUseOperationPrompt as String: "Authenticate to retrieve your token"
]
var result: CFTypeRef?
let status = SecItemCopyMatching(query as CFDictionary, &result)
guard status == errSecSuccess, let data = result as? Data, let token = String(data: data, encoding: .utf8) else {
throw NSError(domain: "KeychainError", code: Int(status))
}
return token
}
The .biometryCurrentSet flag means the item is accessible only with the currently enrolled biometry. If the user adds a new fingerprint or enrolls a new face, access is denied—this prevents unauthorized access after biometric enrollment changes. The kSecUseOperationPrompt in the retrieval query triggers biometric authentication automatically. The OS handles the prompt; your code never sees the biometric data.
This pattern is crucial for storing refresh tokens and API keys. Store short-lived access tokens in memory; store long-lived refresh tokens in biometry-protected Keychain items.
Common mistakes
Not checking canEvaluatePolicy: Calling evaluatePolicy without checking canEvaluatePolicy first will hang or fail silently on devices without biometry. Always guard.
Forgetting Info.plist keys: Missing NSBiometryUsageDescription causes a crash, not a permission prompt. Test on a real device; the simulator may not enforce this as strictly.
Blocking the main thread: LocalAuthentication callbacks run on arbitrary threads. Always dispatch back to the main thread before updating UI. Use DispatchQueue.main.async in every completion handler.
Ignoring lockout errors: After five failed biometric attempts, the system locks out biometry temporarily. Detect LAError.biometryLockedOut and guide users to passcode or a different authentication method rather than retrying endlessly.
Storing secrets in plain Keychain: Keychain without access control is better than UserDefaults, but not by much. Always use SecAccessControl with .biometryCurrentSet for sensitive tokens. This adds a layer the attacker must pass.
Key takeaways
- Use
LAContextandevaluatePolicywith.deviceOwnerAuthenticationWithBiometricsas your primary biometric flow. - Always check
canEvaluatePolicybefore callingevaluatePolicyto avoid hangs on unsupported devices. - Add both
NSBiometryUsageDescriptionandNSFaceIDUsageDescriptionto yourInfo.plistor the app will crash. - Provide a passcode fallback by detecting the
.userFallbackerror and prompting with.deviceOwnerAuthentication. - Protect sensitive credentials in Keychain with
SecAccessControland.biometryCurrentSetto enforce biometric unlock on retrieval. - Always dispatch biometric completion handlers back to the main thread before updating UI.
- Handle
biometryLockedOutgracefully; guide users to alternative authentication methods instead of retrying.






Comments
Loading comments…