Face ID and Touch ID Authentication in iOS
In today's mobile-first world, security and convenience go hand-in-hand. For iOS applications, Apple provides robust biometric authentication mechanisms: Face ID and Touch ID. These technologies allow users to quickly and securely verify their identity using their face or fingerprint, significantly enhancing the user experience while protecting sensitive data.
As intermediate iOS developers, understanding how to properly integrate these features is crucial for building secure and user-friendly applications. This article will guide you through the LocalAuthentication framework, covering how to check for biometric availability, perform authentication, and handle various scenarios and errors gracefully.
The LocalAuthentication Framework
All biometric authentication in iOS is handled by the LocalAuthentication framework. Its core class is LAContext, which manages the authentication process. You'll primarily interact with two methods:
canEvaluatePolicy(_:error:): Checks if the device supports the specified authentication policy and if the necessary biometrics (or passcode) are enrolled.evaluatePolicy(_:localizedReason:reply:): Initiates the authentication process, presenting the user with the Face ID, Touch ID, or passcode prompt.
Understanding Authentication Policies
The LocalAuthentication framework defines several policies that dictate how authentication should occur:
LAPolicy.deviceOwnerAuthenticationWithBiometrics: This policy attempts to authenticate using biometrics (Face ID or Touch ID). If biometrics are not enrolled or available, it will fail. It does not offer a passcode fallback automatically.LAPolicy.deviceOwnerAuthentication: This is generally the preferred policy. It attempts to authenticate using biometrics first. If biometrics are not available, not enrolled, or fail, it automatically offers a system passcode fallback. This provides a better user experience as it gives users an alternative if their biometrics aren't working or they prefer to use their passcode.
Unless you have a very specific reason to restrict authentication solely to biometrics, deviceOwnerAuthentication is usually the way to go.
Checking for Biometric Availability
Before attempting to authenticate, it's crucial to check if the device supports the desired authentication method and if the user has it set up. This prevents presenting an authentication prompt that's guaranteed to fail and allows you to provide a better fallback experience.
import LocalAuthentication
class BiometricAuthenticator {
let context = LAContext()
func canAuthenticate() -> Bool {
var error: NSError?
// Check if the device owner can authenticate using biometrics or passcode
let canEvaluate = context.canEvaluatePolicy(.deviceOwnerAuthentication, error: &error)
if let error = error {
print("Biometric/Passcode availability error: \(error.localizedDescription)")
switch error.code {
case LAError.Code.biometryNotAvailable.rawValue:
print("Biometry is not available on this device.")
case LAError.Code.biometryNotEnrolled.rawValue:
print("Biometry is available but no faces/fingers are enrolled.")
case LAError.Code.passcodeNotSet.rawValue:
print("A passcode is not set on the device.")
case LAError.Code.biometryLockout.rawValue:
print("Biometry is locked out due to too many failed attempts.")
default:
print("Other LAError: \(error.localizedDescription)")
}
return false
}
// If no error, it means the policy can be evaluated.
// We can also check biometryType for more specific info.
if canEvaluate {
switch context.biometryType {
case .faceID:
print("Device supports Face ID.")
case .touchID:
print("Device supports Touch ID.")
case .none:
print("Device supports passcode only.")
@unknown default:
print("Unknown biometry type.")
}
}
return canEvaluate
}
}
In the canAuthenticate() method, we pass nil for the error parameter initially and then check if an NSError was populated. The LAError enumeration provides specific error codes that help you understand why authentication might not be possible.
biometryType Property
The LAContext.biometryType property, available from iOS 11.0, allows you to determine the specific biometric type available on the device (.faceID, .touchID, or .none). This is useful for customizing your UI, for example, by showing a "Use Face ID" or "Use Touch ID" button.
Performing Authentication
Once you've confirmed that authentication is possible, you can initiate the process using evaluatePolicy(_:localizedReason:reply:). This method takes a localizedReason string, which is crucial for good user experience. This string is displayed to the user in the biometric prompt, explaining why your app needs authentication.
extension BiometricAuthenticator {
func authenticate(localizedReason: String, completion: @escaping (Result<Bool, LAError>) -> Void) {
// Ensure authentication is possible before proceeding
guard canAuthenticate() else {
// Handle cases where authentication is not possible (e.g., no biometrics, no passcode)
// For simplicity, we'll just return a generic error here.
// In a real app, you might want to provide a more specific error based on canAuthenticate() checks.
completion(.failure(LAError(.biometryNotAvailable)))
return
}
context.evaluatePolicy(.deviceOwnerAuthentication, localizedReason: localizedReason) { success, error in
DispatchQueue.main.async {
if success {
completion(.success(true))
} else {
if let error = error as? LAError {
completion(.failure(error))
} else {
// Handle unexpected error types
completion(.failure(LAError(.appCancel))) // Or a more appropriate generic error
}
}
}
}
}
}
It's important to note that the reply closure is executed on a background thread. Any UI updates should be dispatched back to the main thread.
Handling Authentication Errors Gracefully
Authentication can fail for various reasons, from user cancellation to biometric lockout. Providing clear feedback and alternative options to the user is key. Here's a breakdown of common LAError codes and how to handle them:
┌──────────────────┐
│ LAError │
│ (error.code) │
└────────┬─────────┘
│
▼
┌──────────────────┐
│ switch error.code │
└────────┬─────────┘
│
├─── LAError.userCancel ───────► User tapped 'Cancel'
│ (Dismiss prompt, do nothing)
│
├─── LAError.userFallback ─────► User tapped 'Enter Passcode'
│ (Offer custom passcode UI or alternative)
│
├─── LAError.systemCancel ─────► System cancelled (e.g., app moved to background)
│ (Dismiss prompt, retry if appropriate)
│
├─── LAError.biometryLockout ──► Too many failed attempts
│ (Prompt for device passcode or wait)
│
├─── LAError.biometryNotEnrolled ► No biometrics set up
│ (Guide user to Settings)
│
├─── LAError.passcodeNotSet ───► Device passcode not set
│ (Guide user to Settings)
│
├─── LAError.biometryNotAvailable ► Device lacks biometry hardware
│ (Hide biometry option, use alternative)
│
└─── default ───────────────────► Other unexpected errors
(Log error, show generic message)
Here's an example of how you might handle these errors within your completion block:
func handleAuthenticationResult(result: Result<Bool, LAError>) {
switch result {
case .success(true):
print("Authentication successful!")
// Proceed with sensitive operation
case .failure(let error):
print("Authentication failed: \(error.localizedDescription)")
switch error.code {
case .userCancel:
print("User cancelled the authentication.")
// Do nothing, or simply dismiss the UI
case .userFallback:
print("User chose to enter passcode.")
// You might present your own passcode entry UI here,
// or let the system handle the passcode fallback if using .deviceOwnerAuthentication
case .systemCancel:
print("System cancelled the authentication (e.g., app went to background).")
// Can retry authentication if appropriate when app returns to foreground
case .biometryLockout:
print("Biometry is locked out. User must enter device passcode or wait.")
// Inform user and potentially offer a device passcode prompt (if .deviceOwnerAuthentication wasn't used)
case .biometryNotEnrolled, .passcodeNotSet:
print("Biometry/Passcode not set up. Please go to Settings.")
// Guide user to iOS Settings
case .biometryNotAvailable:
print("Biometry not available on this device.")
// Fallback to username/password or other method
case .appCancel:
print("Authentication cancelled by app.")
case .invalidContext:
print("LAContext invalid (e.g., used after invalidate()).")
@unknown default:
print("An unknown LAError occurred: \(error.localizedDescription)")
}
// Present an alert to the user or take other recovery steps
}
}
Best Practices and User Experience
- Clear
localizedReason: Always provide a descriptive and user-friendlylocalizedReasonstring. It should clearly explain why your app needs to authenticate the user. E.g., "Unlock app access," "Confirm purchase," "Access protected data." - Use
deviceOwnerAuthentication: As discussed, this policy generally provides the best user experience by offering a system passcode fallback if biometrics are unavailable or fail. - Don't rely solely on biometrics: Biometrics are for convenience and local device security. For highly sensitive operations (e.g., transferring money), consider requiring additional authentication steps like a server-verified password.
- Provide alternatives: If biometrics are not available or fail persistently, ensure your app offers a secure fallback mechanism, such as a username/password login or a custom passcode screen.
- Respect user privacy: Never collect or store biometric data.
LocalAuthenticationonly tells you if the device owner has authenticated; it doesn't provide any biometric information to your app. - Handle
LAContextlifecycle: Create a newLAContextinstance for each authentication attempt or ensure your existing context is valid. Reusing a context that has become invalid can lead toLAError.invalidContext.
Complete Example: Integrating into a SwiftUI View
Here's how you might integrate our BiometricAuthenticator into a SwiftUI view. Remember to add NSFaceIDUsageDescription to your Info.plist file, explaining why your app needs Face ID. If you only use Touch ID, this is not strictly necessary, but it's good practice to provide a clear reason for any biometric access.
<key>NSFaceIDUsageDescription</key>
<string>Your face will be used to unlock access to your secure data.</string>
import SwiftUI
import LocalAuthentication // Make sure to import this!
struct ContentView: View {
@State private var isAuthenticated = false
@State private var authenticationError: LAError?
@State private var showingAlert = false
@State private var alertMessage = ""
private let authenticator = BiometricAuthenticator()
var body: some View {
VStack {
if isAuthenticated {
Text("Welcome, authenticated user!")
.font(.title)
.padding()
Button("Logout (Reset State)") {
isAuthenticated = false
}
} else {
Text("Please authenticate to proceed.")
.font(.title2)
.padding()
Button("Authenticate with Biometrics") {
authenticateUser()
}
.padding()
.background(Color.blue)
.foregroundColor(.white)
.cornerRadius(10)
}
}
.alert("Authentication Failed", isPresented: $showingAlert) {
Button("OK") { }
} message: {
Text(alertMessage)
}
}
private func authenticateUser() {
guard authenticator.canAuthenticate() else {
alertMessage = "Biometric or passcode authentication is not available or set up on this device."
showingAlert = true
return
}
let reason = "To access your secure content."
authenticator.authenticate(localizedReason: reason) { result in
switch result {
case .success(true):
self.isAuthenticated = true
self.authenticationError = nil
case .failure(let error):
self.isAuthenticated = false
self.authenticationError = error
self.alertMessage = self.errorMessage(for: error)
self.showingAlert = true
}
}
}
private func errorMessage(for error: LAError) -> String {
switch error.code {
case .userCancel:
return "Authentication cancelled by user."
case .userFallback:
return "User chose to enter passcode. Please use device passcode."
case .systemCancel:
return "Authentication cancelled by system."
case .biometryLockout:
return "Too many failed attempts. Biometrics locked out. Please use device passcode or try again later."
case .biometryNotEnrolled:
return "Biometrics not set up. Please enable Face ID or Touch ID in device settings."
case .passcodeNotSet:
return "Device passcode not set. Please set a passcode in device settings."
case .biometryNotAvailable:
return "Biometrics not available on this device."
case .appCancel:
return "Authentication cancelled by the application."
case .invalidContext:
return "The authentication context is invalid."
@unknown default:
return "An unknown authentication error occurred."
}
}
}
This ContentView provides a basic example of how to trigger authentication and handle its outcomes, displaying different UI states based on authentication success or failure.
Summary
Integrating Face ID and Touch ID into your iOS applications significantly boosts both security and user convenience. By leveraging the LocalAuthentication framework, you can check for biometric availability, initiate authentication, and handle various success and error scenarios. Always prioritize clear communication with the user through the localizedReason and provide graceful fallbacks for when biometrics are not available or fail. Remember to add the NSFaceIDUsageDescription to your Info.plist if your app uses Face ID.
Happy Swifting!