How to Add Face ID / Touch ID Authentication to a SwiftUI App
Gate a screen behind biometrics using the LocalAuthentication framework — LAContext, canEvaluatePolicy, evaluate, the required Info.plist key, passcode fallback, and every error you need to handle.
Biometric authentication in SwiftUI uses the LocalAuthentication framework. You create an LAContext, call canEvaluatePolicy(_:error:) to confirm Face ID or Touch ID is available, then evaluate(_:localizedReason:) to prompt the user. Add the NSFaceIDUsageDescription key to Info.plist or Face ID will crash your app. Use the policy .deviceOwnerAuthentication (biometrics with automatic passcode fallback) or .deviceOwnerAuthenticationWithBiometrics (biometrics only), and switch over LAError codes to handle lockout, cancellation, and unenrolled devices.
.deviceOwnerAuthentication vs .deviceOwnerAuthenticationWithBiometrics
The policy you pass to evaluatePolicy decides whether passcode fallback is automatic. Pick based on how sensitive the gated screen is and whether you want to force a live biometric read.
- .deviceOwnerAuthentication: tries biometrics, then automatically offers device passcode — best default for most apps.
- .deviceOwnerAuthenticationWithBiometrics: biometrics only, no passcode fallback — use when you specifically need a live face/finger.
- With the biometrics-only policy, a lockout (.biometryLockout) can only be cleared by the user entering their passcode elsewhere.
- Set context.localizedFallbackTitle to customize the fallback button text; set it to "" to hide it entirely.
Testing Face ID in the simulator
You don't need a physical device to test the happy and sad paths. The iOS Simulator can enroll a synthetic Face ID and simulate matching and non-matching scans from the Features menu, which is enough to exercise your unlock flow and error handling.
- Features > Face ID > Enrolled toggles whether biometry is set up.
- Features > Face ID > Matching Face simulates a successful scan.
- Features > Face ID > Non-matching Face triggers an authentication failure so you can test error copy.
- Toggle Enrolled off to hit the .biometryNotEnrolled branch.
// Re-lock when the app returns from background:
.onChange(of: scenePhase) { _, phase in
if phase == .background {
auth.isUnlocked = false
}
}
// Declare: @Environment(\.scenePhase) private var scenePhaseBiometric lock without the edge cases
The Swift Kit ships a ready-made biometric lock screen and an LAContext wrapper that handles lockout, cancellation, and biometry-type detection — gate any view with one modifier.
Add biometric authentication step by step
We'll add the required Info.plist key, wrap LAContext in a small async authenticator, gate a screen behind it, and handle every meaningful error. Code targets Swift 6 and iOS 16+.
- 1
Add NSFaceIDUsageDescription to Info.plist
Face ID requires a usage-description string or the app crashes the instant you invoke it. Touch ID does not need this key, but adding it is harmless and future-proofs the app for Face ID devices.
// Info.plist // <key>NSFaceIDUsageDescription</key> // <string>We use Face ID to keep your account secure.</string> // // This string appears in the system Face ID permission prompt. - 2
Check availability with canEvaluatePolicy
Before prompting, confirm the device actually supports biometrics and the user is enrolled. canEvaluatePolicy fills an NSError explaining why if it returns false — for example, biometry not enrolled or hardware unavailable.
import LocalAuthentication func biometryAvailable() -> Bool { let context = LAContext() var error: NSError? let available = context.canEvaluatePolicy( .deviceOwnerAuthenticationWithBiometrics, error: &error ) if let error { print("Biometry unavailable: \(error.localizedDescription)") } return available } - 3
Wrap authentication in an async method
evaluate(_:localizedReason:) has an async overload on modern iOS, which reads far cleaner than the completion-handler version. Create a fresh LAContext per attempt — reusing one carries stale evaluation state.
import LocalAuthentication enum BiometricError: Error { case unavailable case failed(String) } @MainActor final class BiometricAuthenticator: ObservableObject { @Published var isUnlocked = false func authenticate() async { let context = LAContext() context.localizedFallbackTitle = "Enter Passcode" var error: NSError? guard context.canEvaluatePolicy(.deviceOwnerAuthentication, error: &error) else { print("Cannot evaluate: \(error?.localizedDescription ?? "unknown")") return } do { let success = try await context.evaluatePolicy( .deviceOwnerAuthentication, localizedReason: "Unlock your vault" ) isUnlocked = success } catch { handle(error) } } } - 4
Gate a screen behind the unlock
Show a lock screen until isUnlocked flips true. Kick off authentication in .task so it runs as soon as the view appears, and offer a manual retry button for users who dismissed the prompt.
struct LockedView: View { @StateObject private var auth = BiometricAuthenticator() var body: some View { if auth.isUnlocked { SecretVaultView() } else { VStack(spacing: 20) { Image(systemName: "faceid") .font(.system(size: 64)) Text("Vault Locked") .font(.title2.bold()) Button("Unlock with Face ID") { Task { await auth.authenticate() } } .buttonStyle(.borderedProminent) } .task { await auth.authenticate() } } } } - 5
Handle the LAError cases
Real devices fail in specific ways: the user cancels, biometrics lock out after too many attempts, or no face/finger is enrolled. Switch over LAError.Code and respond appropriately — often by falling back to passcode or showing guidance.
extension BiometricAuthenticator { func handle(_ error: Error) { guard let laError = error as? LAError else { print("Unknown error: \(error)") return } switch laError.code { case .userCancel, .appCancel, .systemCancel: break // User dismissed; leave locked. case .userFallback: print("User chose passcode fallback") case .biometryLockout: print("Locked out — passcode required to re-enable") case .biometryNotEnrolled: print("No Face ID / Touch ID enrolled") case .biometryNotAvailable: print("Biometry unavailable on this device") default: print("Authentication failed: \(laError.localizedDescription)") } } } - 6
Detect the biometry type for correct UI copy
Don't hard-code "Face ID" — a Touch ID iPhone SE user will be confused. LAContext exposes biometryType after canEvaluatePolicy runs, so you can label buttons correctly.
func biometryLabel() -> String { let context = LAContext() _ = context.canEvaluatePolicy(.deviceOwnerAuthentication, error: nil) switch context.biometryType { case .faceID: return "Unlock with Face ID" case .touchID: return "Unlock with Touch ID" case .opticID: return "Unlock with Optic ID" default: return "Unlock" } }
Frequently Asked Questions
Why does my app crash the moment it triggers Face ID authentication?
What's the difference between .deviceOwnerAuthentication and .deviceOwnerAuthenticationWithBiometrics?
How do I handle the user canceling the Face ID prompt in SwiftUI?
Should I reuse one LAContext or create a new one per authentication attempt?
How do I re-lock the biometric screen when the app goes to the background?
Keep exploring
Lock sensitive screens the right way
The Swift Kit is a $99 one-time SwiftUI boilerplate with biometric auth, Supabase login, RevenueCat paywalls, and onboarding already wired up. Lifetime updates, 14-day refund.
Get The Swift Kit — $99One-time purchase · Lifetime updates · 14-day refund