SwiftUI Build Guide

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.

Last updated: 2026-07-17 9 min read By Ahmed Gagan, iOS Engineer
Quick Answer

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.

Framework
LocalAuthentication (LAContext)
Required Info.plist key
NSFaceIDUsageDescription
Policy with fallback
.deviceOwnerAuthentication
In The Swift Kit
Biometric lock screen and LAContext wrapper ready to gate any view

.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.
Guarding a re-lock on background
// Re-lock when the app returns from background:
.onChange(of: scenePhase) { _, phase in
    if phase == .background {
        auth.isUnlocked = false
    }
}
// Declare: @Environment(\.scenePhase) private var scenePhase

Biometric 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.

Get The Swift Kit — $99

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. 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. 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. 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. 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. 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. 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?
You're missing the NSFaceIDUsageDescription key in Info.plist. Face ID (unlike Touch ID) requires a purpose string, and calling evaluatePolicy without it triggers an immediate crash. Add NSFaceIDUsageDescription with a short sentence explaining why you need Face ID, and the system permission prompt will show it.
What's the difference between .deviceOwnerAuthentication and .deviceOwnerAuthenticationWithBiometrics?
.deviceOwnerAuthentication tries biometrics first and then automatically falls back to the device passcode, which is the friendliest default. .deviceOwnerAuthenticationWithBiometrics restricts authentication to Face ID or Touch ID only, with no passcode fallback — use it when you specifically need a live biometric read and are prepared to handle .biometryLockout yourself.
How do I handle the user canceling the Face ID prompt in SwiftUI?
Cast the thrown error to LAError and switch on its code. The cancellation cases are .userCancel (tapped Cancel), .systemCancel (system interrupted), and .appCancel. In each you typically do nothing and leave the screen locked, offering a manual Unlock button so the user can retry the biometric prompt when ready.
Should I reuse one LAContext or create a new one per authentication attempt?
Create a fresh LAContext for each attempt. An LAContext caches its evaluation result, so reusing one can make a second authentication silently succeed without re-prompting the user. Instantiating a new context per authenticate() call guarantees the user is actually challenged every time you gate the screen.
How do I re-lock the biometric screen when the app goes to the background?
Observe @Environment(\.scenePhase) and, in an .onChange handler, set your isUnlocked flag back to false when the phase becomes .background. When the user returns, your LockedView shows the lock screen again and its .task re-runs authenticate(), forcing a fresh Face ID or Touch ID check.

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 — $99

One-time purchase · Lifetime updates · 14-day refund