LocalAuthentication: Adding Face ID and Touch ID to Your App


Users expect sensitive screens — financial dashboards, saved passwords, private notes — to be gated behind Face ID or Touch ID. The good news: Apple’s LocalAuthentication framework makes biometric authentication a single async call. The bad news: most implementations get the fallback flow, error handling, and Keychain integration wrong, leaving either a broken experience or a false sense of security.

This post covers LAContext end to end: checking biometric availability, evaluating policies, handling every error case, building proper fallback flows, and integrating with the Keychain for biometric-protected credential storage. We won’t cover Passkeys or ASAuthorization — those are in Passkeys and AuthenticationServices.

Contents

The Problem

Picture the Incredibles’ family vault — a secure room in their house where they store super-suit prototypes, mission dossiers, and Edna Mode’s confidential designs. Bob Parr wants to unlock the vault with Face ID, but if biometrics fail (maybe he is wearing his mask), he needs a passcode fallback. And the most sensitive items — Edna’s design files — should only be accessible when biometric authentication succeeds, not with a passcode alone.

Here is what a naive biometric check looks like:

import LocalAuthentication

func unlockVault() {
    let context = LAContext()
    context.evaluatePolicy(
        .deviceOwnerAuthenticationWithBiometrics,
        localizedReason: "Access the Incredibles Vault"
    ) { success, error in
        if success {
            // Show vault contents
        }
    }
}

This looks simple, but it has several problems. It does not check whether biometrics are available before presenting the prompt. It ignores the error entirely, so users get no feedback when authentication fails. It uses .deviceOwnerAuthenticationWithBiometrics which has no fallback — if Face ID fails three times, the user is locked out with no recourse. And most critically, it only gates the UI. The underlying data is still accessible to anyone who can read the app’s sandbox. Real security requires tying the data itself to biometric authentication through the Keychain.

Understanding LAContext and Policies

LAContext is the entry point for all biometric operations. Each context instance represents a single authentication session. Two methods matter:

  • canEvaluatePolicy(_:error:) — Checks whether a specific authentication policy is available on the current device. Call this before attempting authentication.
  • evaluatePolicy(_:localizedReason:) — Presents the biometric prompt and returns the result.

The Two Policies

PolicyBiometricsPasscode FallbackUse Case
.deviceOwnerAuthenticationWithBiometricsRequiredNo fallbackWhen you need biometric-only access (e.g., confirming identity for a financial transaction).
.deviceOwnerAuthenticationPreferredFalls back to device passcodeWhen you need authentication but don’t care whether it’s biometric or passcode.

The choice between these policies is an architectural decision, not a UI preference. If your security model requires biometric proof (the user physically present), use .deviceOwnerAuthenticationWithBiometrics. If you just need to confirm the device owner is present, use .deviceOwnerAuthentication — it provides a better user experience because passcode is always available as a fallback.

Apple Docs: LAPolicy — LocalAuthentication

Implementing Biometric Authentication

Here is a production-grade authentication service for the Incredibles Vault. It checks availability first, uses the async/await variant introduced in iOS 16, and surfaces meaningful results.

import LocalAuthentication

@Observable
final class BiometricAuthService {
    private(set) var biometricType: LABiometryType = .none
    private(set) var isAuthenticated = false

    enum AuthResult {
        case success
        case biometricsUnavailable(reason: String)
        case failed(LAError)
        case cancelled
    }

    /// Checks what biometric hardware is available.
    func checkBiometricAvailability() -> LABiometryType {
        let context = LAContext()
        var error: NSError?
        let canEvaluate = context.canEvaluatePolicy(
            .deviceOwnerAuthenticationWithBiometrics,
            error: &error
        )

        if canEvaluate {
            biometricType = context.biometryType
        } else {
            biometricType = .none
        }

        return biometricType
    }

    /// Authenticates the user with biometrics, falling back to passcode.
    func authenticate(reason: String) async -> AuthResult {
        let context = LAContext()
        context.localizedCancelTitle = "Use Password"
        context.localizedFallbackTitle = "Enter Passcode"

        // Check availability first
        var error: NSError?
        guard context.canEvaluatePolicy(
            .deviceOwnerAuthentication,
            error: &error
        ) else {
            let message = error?.localizedDescription ?? "Biometrics unavailable"
            return .biometricsUnavailable(reason: message)
        }

        do {
            let success = try await context.evaluatePolicy(
                .deviceOwnerAuthentication,
                localizedReason: reason
            )

            if success {
                isAuthenticated = true
                return .success
            } else {
                return .failed(LAError(.authenticationFailed))
            }
        } catch let laError as LAError {
            if laError.code == .userCancel || laError.code == .appCancel {
                return .cancelled
            }
            return .failed(laError)
        } catch {
            return .failed(LAError(.authenticationFailed))
        }
    }

    /// Resets the authentication state — call when locking the vault.
    func deauthenticate() {
        isAuthenticated = false
    }
}

A few important details. First, create a new LAContext for each authentication attempt. Reusing a context after a successful authentication skips the biometric prompt — the context caches the result. This is useful in some scenarios (see Advanced Usage below), but dangerous if you want to re-verify the user’s identity.

Second, localizedReason is the string shown below the Face ID or Touch ID prompt. Apple requires this to explain why authentication is needed. Vague strings like “Authenticate” will be rejected during App Review. Use something specific: “Access the Incredibles Vault” or “Authorize transfer of $500.”

SwiftUI Integration

Wire the service into a view that gates access to sensitive content:

struct VaultView: View {
    @State private var authService = BiometricAuthService()
    @State private var authError: String?

    var body: some View {
        Group {
            if authService.isAuthenticated {
                VaultContentsView()
            } else {
                VaultLockedView(
                    biometricType: authService.biometricType,
                    errorMessage: authError,
                    onUnlock: unlock
                )
            }
        }
        .onAppear {
            _ = authService.checkBiometricAvailability()
        }
    }

    private func unlock() {
        Task {
            let result = await authService.authenticate(
                reason: "Unlock the Incredibles Vault to view classified missions"
            )

            switch result {
            case .success:
                authError = nil
            case .biometricsUnavailable(let reason):
                authError = reason
            case .failed(let error):
                authError = describeError(error)
            case .cancelled:
                authError = nil // User chose to cancel, no error to show
            }
        }
    }

    private func describeError(_ error: LAError) -> String {
        switch error.code {
        case .biometryLockout:
            return "Too many failed attempts. Use your device passcode to re-enable Face ID."
        case .biometryNotEnrolled:
            return "No biometric data enrolled. Go to Settings > Face ID & Passcode."
        default:
            return "Authentication failed. Please try again."
        }
    }
}

Tip: Add NSFaceIDUsageDescription to your Info.plist with a string explaining why your app uses Face ID. This is required on devices with Face ID and distinct from the localizedReason parameter — the Info.plist string appears in the system permissions dialog the first time Face ID is used, while localizedReason appears on every authentication prompt.

Handling Errors and Fallbacks

LAError has many codes, and handling them correctly is what separates a polished app from a frustrating one. Here is the complete error taxonomy:

extension LAError.Code {
    var userFacingDescription: String {
        switch self {
        case .authenticationFailed:
            // Biometric did not match. The system shows its own
            // "Try Again" prompt, so you rarely need to handle this.
            return "Authentication failed."

        case .userCancel:
            // User tapped Cancel on the biometric prompt.
            return "Authentication was cancelled."

        case .userFallback:
            // User tapped the fallback button (e.g., "Enter Password").
            // Present your own password entry UI.
            return "User chose to enter a password."

        case .systemCancel:
            // System cancelled (e.g., another app came to foreground).
            return "Authentication interrupted by the system."

        case .passcodeNotSet:
            // No device passcode is configured.
            return "Please set a device passcode in Settings."

        case .biometryNotAvailable:
            // Hardware not present or disabled by MDM/restrictions.
            return "Biometric authentication is not available."

        case .biometryNotEnrolled:
            // Hardware present but no face/fingerprint enrolled.
            return "No biometric data enrolled. Set up Face ID or Touch ID in Settings."

        case .biometryLockout:
            // Too many failed attempts. Requires passcode to re-enable.
            return "Biometrics locked. Enter your device passcode to continue."

        default:
            return "An unknown authentication error occurred."
        }
    }
}

The .userFallback code deserves special attention. It fires when the user taps the fallback button on the biometric prompt — the one you labeled with localizedFallbackTitle. When this fires, you should present your own custom password entry UI, not rely on the system. If you are using .deviceOwnerAuthentication, the system handles the passcode fallback automatically, and .userFallback is never emitted.

Warning: .biometryLockout means the user has failed biometric authentication too many times (5 consecutive failures for Face ID, 3 for Touch ID). The only way to reset the lockout is a successful device passcode entry. If you are using .deviceOwnerAuthenticationWithBiometrics, your app cannot recover from this state. Switch to .deviceOwnerAuthentication if you want the system passcode to serve as a recovery path.

Keychain Integration with Biometric Protection

The BiometricAuthService above gates the UI, but it does not protect the data. If someone accesses the app’s sandbox (through a backup, jailbreak, or device exploit), the data is still readable. For real security, store sensitive credentials in the Keychain with biometric access control.

The Keychain’s SecAccessControl lets you require biometric authentication before a Keychain item can be read. The item is encrypted with a key that the Secure Enclave only releases after biometric verification.

import Security

struct BiometricKeychain {
    enum KeychainError: Error {
        case saveFailed(OSStatus)
        case readFailed(OSStatus)
        case deleteFailed(OSStatus)
        case accessControlCreationFailed
        case unexpectedData
    }

    /// Saves Edna Mode's design credentials, protected by biometrics.
    static func save(
        credentials: Data,
        forAccount account: String,
        service: String = "com.incredibles.vault"
    ) throws {
        // Create access control requiring biometrics
        guard let accessControl = SecAccessControlCreateWithFlags(
            nil,
            kSecAttrAccessibleWhenPasscodeSetThisDeviceOnly,
            .biometryCurrentSet,
            nil
        ) else {
            throw KeychainError.accessControlCreationFailed
        }

        // Build the query
        let query: [String: Any] = [
            kSecClass as String: kSecClassGenericPassword,
            kSecAttrService as String: service,
            kSecAttrAccount as String: account,
            kSecValueData as String: credentials,
            kSecAttrAccessControl as String: accessControl,
            kSecUseAuthenticationContext as String: LAContext()
        ]

        // Delete existing item if present
        SecItemDelete(query as CFDictionary)

        let status = SecItemAdd(query as CFDictionary, nil)
        guard status == errSecSuccess else {
            throw KeychainError.saveFailed(status)
        }
    }

    /// Reads credentials — triggers biometric prompt automatically.
    static func read(
        account: String,
        service: String = "com.incredibles.vault",
        reason: String = "Access Edna Mode's design credentials"
    ) throws -> Data {
        let context = LAContext()
        context.localizedReason = reason

        let query: [String: Any] = [
            kSecClass as String: kSecClassGenericPassword,
            kSecAttrService as String: service,
            kSecAttrAccount as String: account,
            kSecReturnData as String: true,
            kSecMatchLimit as String: kSecMatchLimitOne,
            kSecUseAuthenticationContext as String: context
        ]

        var result: AnyObject?
        let status = SecItemCopyMatching(query as CFDictionary, &result)

        guard status == errSecSuccess, let data = result as? Data else {
            throw KeychainError.readFailed(status)
        }

        return data
    }

    /// Removes credentials from the Keychain.
    static func delete(
        account: String,
        service: String = "com.incredibles.vault"
    ) throws {
        let query: [String: Any] = [
            kSecClass as String: kSecClassGenericPassword,
            kSecAttrService as String: service,
            kSecAttrAccount as String: account,
        ]

        let status = SecItemDelete(query as CFDictionary)
        guard status == errSecSuccess || status == errSecItemNotFound else {
            throw KeychainError.deleteFailed(status)
        }
    }
}

The critical flag is .biometryCurrentSet. This means:

  • The item is only accessible after biometric authentication.
  • If the user enrolls a new fingerprint or resets Face ID, the item becomes inaccessible (you will get errSecAuthFailed). This is a security feature: it prevents someone from adding their own biometrics to a stolen device and then accessing existing credentials.

Alternative flags include .biometryAny (survives biometric enrollment changes — less secure but more convenient) and .userPresence (allows either biometrics or passcode).

Apple Docs: SecAccessControlCreateWithFlags — Security

Advanced Usage

Reusing Authentication Context

An LAContext caches a successful authentication for a configurable duration. This is useful when Dash Parr needs to perform multiple secure operations in quick succession without being prompted every time:

func performMultipleSecureOperations() async {
    let context = LAContext()
    // Authentication result stays valid for 30 seconds
    context.touchIDAuthenticationAllowableReuseDuration = 30

    do {
        try await context.evaluatePolicy(
            .deviceOwnerAuthenticationWithBiometrics,
            localizedReason: "Access Dash's speed training records"
        )

        // All of these use the cached authentication — no re-prompt
        let records = try BiometricKeychain.read(
            account: "dash-training-records"
        )
        let missions = try BiometricKeychain.read(
            account: "dash-missions"
        )
        // Process records and missions...
    } catch {
        // Handle error
    }
}

Warning: Setting touchIDAuthenticationAllowableReuseDuration too high (e.g., 300 seconds) weakens your security posture. Someone could authenticate, put the phone down, and another person could access the data within the reuse window. Apple caps this at LATouchIDAuthenticationMaximumAllowableReuseDuration (5 minutes). For financial operations, keep it at 0 (the default) to require fresh authentication every time.

Invalidating Context on Biometric Changes

If you need to detect when biometrics change (e.g., a new face is enrolled), compare the evaluatedPolicyDomainState property across sessions:

final class BiometricIntegrityChecker {
    private let domainStateKey = "com.incredibles.vault.biometricDomainState"

    /// Returns true if biometric enrollment has changed since the last check.
    func hasBiometricEnrollmentChanged() -> Bool {
        let context = LAContext()
        var error: NSError?
        guard context.canEvaluatePolicy(
            .deviceOwnerAuthenticationWithBiometrics,
            error: &error
        ) else {
            return true // Assume changed if we can't evaluate
        }

        guard let currentState = context.evaluatedPolicyDomainState else {
            return true
        }

        let storedState = UserDefaults.standard.data(forKey: domainStateKey)

        if storedState == nil {
            // First run — store the current state
            UserDefaults.standard.set(currentState, forKey: domainStateKey)
            return false
        }

        return currentState != storedState
    }

    /// Updates the stored domain state after user confirms their identity.
    func updateStoredDomainState() {
        let context = LAContext()
        var error: NSError?
        guard context.canEvaluatePolicy(
            .deviceOwnerAuthenticationWithBiometrics,
            error: &error
        ),
        let state = context.evaluatedPolicyDomainState else {
            return
        }

        UserDefaults.standard.set(state, forKey: domainStateKey)
    }
}

This is useful for high-security apps (banking, password managers) where you want to force re-authentication or re-enrollment when biometric data changes. Violet Parr enrolling a new face should trigger a verification flow before she can access the family vault with the updated biometrics.

When to Use (and When Not To)

ScenarioRecommendation
Gating access to a sensitive screen (private notes, health data)Use LAContext with .deviceOwnerAuthentication for biometrics with passcode fallback.
Protecting stored credentials (API tokens, passwords)Store in Keychain with .biometryCurrentSet access control — the OS handles the prompt.
Confirming identity before a financial transactionUse .deviceOwnerAuthenticationWithBiometrics (no passcode fallback) for stronger proof of presence.
App-level lock screen on every foreground entryUse LAContext in sceneDidBecomeActive — but keep touchIDAuthenticationAllowableReuseDuration short.
Replacing a login system entirelyDo not. LocalAuthentication verifies the device owner, not your user’s identity. Pair it with server-side authentication or Passkeys.
Protecting data at rest from forensic extractionKeychain with .biometryCurrentSet plus kSecAttrAccessibleWhenPasscodeSetThisDeviceOnly. Data is unrecoverable without the passcode.

The key distinction: LAContext.evaluatePolicy is a local check. It verifies that the person holding the device is the device owner. It does not authenticate the user against your backend, and it does not create any token or credential you can send to a server. For server-verified identity, combine LocalAuthentication with a server challenge or move to Passkeys.

Summary

  • Use .deviceOwnerAuthentication for biometrics with automatic passcode fallback — best user experience for most apps.
  • Use .deviceOwnerAuthenticationWithBiometrics only when you need biometric-specific proof of presence (financial confirmations).
  • Always call canEvaluatePolicy before evaluatePolicy and handle every LAError.Code — especially .biometryLockout and .userFallback.
  • For real data protection, store credentials in the Keychain with SecAccessControl and .biometryCurrentSet — this ties decryption to the Secure Enclave, not just a UI gate.
  • Create a fresh LAContext for each authentication unless you explicitly want cached results via touchIDAuthenticationAllowableReuseDuration.

For the next generation of passwordless authentication, see Passkeys and AuthenticationServices. For a broader look at securing your app’s data and transport, check out App Security.