iOS Biometric Authentication Deep Dive — Face ID, Touch ID, and LocalAuthentication

Updated Jun 01, 2026#ios#security#swiftui#keychain

Biometric authentication lets users verify identity with their fingerprint or face. On iOS, the LocalAuthentication framework mediates between your app and the Secure Enclave — a dedicated hardware coprocessor that isolates cryptographic operations from the main CPU and OS.

The system handles all data capture and matching. Your app never sees fingerprint data, face maps, or the mathematical representation stored in the Secure Enclave.

Project Setup

Info.plist

For Face ID, include NSFaceIDUsageDescription (Privacy — Face ID Usage Description) with a string explaining why your app needs biometrics:

<key>NSFaceIDUsageDescription</key>
<string>Unlock your account and authorize purchases.</string>

The system shows this string when requesting Face ID access. Without this key, authorization requests may fail on Face ID devices.

Touch ID does not require a usage description key.

Entitlements

No special entitlement is needed for biometric authentication. Keychain biometric integration may require the Keychain Access Groups entitlement.

LAContext Lifecycle

An LAContext brokers authentication between your app and the Secure Enclave.

Create

let context = LAContext()

A context begins in a fresh state. You can customize the UI before evaluation:

context.localizedCancelTitle = "Use Password"
context.localizedFallbackTitle = "Enter Passcode"
  • localizedCancelTitle replaces the Cancel button text.
  • localizedFallbackTitle replaces the fallback button title. Leave it as nil to use the system default, or set it to an empty string to hide the button.

If you label the cancel button “Use Password”, route cancellation to your app’s password flow.

Evaluate

let context = LAContext()
var error: NSError?

guard context.canEvaluatePolicy(.deviceOwnerAuthentication, error: &error) else {
  // Device authentication is unavailable. Offer an app-level fallback.
  return
}

let reason = "Authenticate to access your account"
guard try await context.evaluatePolicy(
  .deviceOwnerAuthentication,
  localizedReason: reason
) else { return }

// Continue with the protected action.

The evaluatePolicy call presents the system authentication UI, handles the interaction, and communicates with the Secure Enclave. It returns a Boolean result or throws an error. The async/await variant requires iOS 16+.

Invalidate

Call invalidate() when a context is no longer needed:

context.invalidate()

This is useful for logout flows — the user should re-authenticate with a fresh context.

Do not reuse a context across different users or sessions. Create a new LAContext per authentication attempt.

Detect Enrollment Changes

Use the biometric domain-state hash when you need to detect whether enrolled biometrics changed:

if #available(iOS 18.0, *) {
  let stateHash = context.domainState.biometry.stateHash
}

The hash is opaque, app-specific data. Compare it with a previously stored value to detect a change. It does not reveal which biometric changed, and it does not revoke access by itself. For secrets that must become inaccessible after enrollment changes, protect the Keychain item with .biometryCurrentSet.

Older code may use evaluatedPolicyDomainState. That property is deprecated; prefer domainState.biometry.stateHash on supported OS versions.

Detecting Available Biometrics

Use canEvaluatePolicy to check availability, then read biometryType to determine the hardware:

let context = LAContext()
var error: NSError?

guard context.canEvaluatePolicy(.deviceOwnerAuthenticationWithBiometrics, error: &error) else {
  handleBiometryUnavailable(error)
  return
}

switch context.biometryType {
case .touchID:
  showFingerprintUI()
case .faceID:
  showFaceUI()
case .none:
  showPasswordUI()
@unknown default:
  showGenericBiometryUI()
}

Use .deviceOwnerAuthenticationWithBiometrics when checking whether biometrics are currently usable. The broader .deviceOwnerAuthentication policy can succeed when only the device passcode is available.

On visionOS, LocalAuthentication also supports Optic ID. Keep an @unknown default branch in iOS-focused code so new biometric types do not break your UI assumptions.

Policy Selection

Policy Passcode fallback Use case
deviceOwnerAuthenticationWithBiometrics No Flows that explicitly require biometrics
deviceOwnerAuthentication Yes General authentication, login

With deviceOwnerAuthenticationWithBiometrics, the user must have enrolled biometrics. Choose it only when the product requirement explicitly excludes device-passcode fallback.

With deviceOwnerAuthentication, on biometrics failure the system prompts for the device passcode. This is the recommended default for most apps — the user escalates naturally from fingerprint or face to passcode to your own fallback.

Keychain + Biometrics

The most common real-world use of biometrics is securing Keychain items. A biometry-protected item requires the user to authenticate before the Keychain returns the secret.

import Security

guard let accessControl = SecAccessControlCreateWithFlags(
  nil,
  kSecAttrAccessibleWhenPasscodeSetThisDeviceOnly,
  .biometryCurrentSet,
  nil
) else { return }

let query: [String: Any] = [
  kSecClass as String: kSecClassGenericPassword,
  kSecAttrAccount as String: "user_token",
  kSecValueData as String: Data("secret-token".utf8),
  kSecAttrAccessControl as String: accessControl,
]

let addStatus = SecItemAdd(query as CFDictionary, nil)
// Check addStatus == errSecSuccess in production

On read, the system prompts for biometrics automatically:

let query: [String: Any] = [
  kSecClass as String: kSecClassGenericPassword,
  kSecAttrAccount as String: "user_token",
  kSecReturnData as String: true,
  kSecUseAuthenticationContext as String: LAContext(),
]

var result: AnyObject?
let readStatus = SecItemCopyMatching(query as CFDictionary, &result)
// Check readStatus == errSecSuccess in production

The kSecUseAuthenticationContext accepts an LAContext, which lets you customize the prompt. Use .biometryCurrentSet for biometrics-only access, or .userPresence for biometrics + passcode fallback.

Prefer Keychain access control over unlocking an in-memory secret with evaluatePolicy alone. The Keychain enforces authentication before returning the protected value. After retrieval, your app must still minimize the secret’s lifetime in memory.

Face ID UI Considerations

Face ID behaves differently from Touch ID in two important ways:

  1. No confirmation prompt — scanning starts immediately when you call evaluatePolicy. Touch ID waits for finger placement.
  2. Eye contact — Face ID requires the user to look at the device (eyes open, facing the camera). Users can disable this requirement in Settings > Accessibility.

Keep your surrounding UI consistent with biometryType, but let the system prompt guide the authentication interaction.

Error Handling

Handle expected LAError.Code cases and keep an @unknown default branch for new cases:

do {
  let authenticated = try await context.evaluatePolicy(
    .deviceOwnerAuthentication,
    localizedReason: reason
  )
  guard authenticated else { return }
  // Continue with the protected action.
} catch let error as LAError {
  switch error.code {
  case .authenticationFailed:
    // Biometric mismatch or invalid passcode
  case .userCancel:
    // User tapped Cancel
    return // silent
  case .userFallback:
    // User tapped fallback button
    // Show password UI
  case .biometryNotAvailable:
    // No biometric hardware on device
  case .biometryNotEnrolled:
    // No fingerprints or face registered
  case .biometryLockout:
    // Too many failures. Biometrics locked.
    // Evaluate with deviceOwnerAuthentication to offer passcode
  case .passcodeNotSet:
    // No passcode set on device
  @unknown default:
    break
  }
}
Error Cause Recovery
.authenticationFailed Biometric or passcode mismatch Retry
.userCancel User dismissed the dialog No action needed
.userFallback User tapped the fallback button Show alternate auth
.biometryNotAvailable Biometrics are unavailable Offer another authentication method
.biometryNotEnrolled No fingerprints or face registered Prompt to enroll in Settings
.biometryLockout Too many failed attempts Escalate to passcode via deviceOwnerAuthentication
.biometryNotPaired Required biometric accessory is not paired Explain the required setup
.biometryDisconnected Paired biometric accessory is disconnected Ask the user to reconnect it
.passcodeNotSet No device passcode Prompt to set a passcode
.invalidContext Context was already invalidated Create a new LAContext

SwiftUI Integration

In SwiftUI, keep the LAContext lifecycle decoupled from views. Use an @Observable service:

import LocalAuthentication
import Observation

@MainActor
@Observable
final class BiometricService {
  private(set) var isAuthenticated = false
  private(set) var biometryType: LABiometryType = .none
  private(set) var error: String?

  func checkAvailability() {
    let context = LAContext()
    var nsError: NSError?
    guard context.canEvaluatePolicy(
      .deviceOwnerAuthenticationWithBiometrics,
      error: &nsError
    ) else {
      error = nsError?.localizedDescription
      return
    }
    biometryType = context.biometryType
  }

  func authenticate() async {
    let context = LAContext()
    let reason = "Authenticate to access your account"
    do {
      isAuthenticated = try await context.evaluatePolicy(
        .deviceOwnerAuthentication,
        localizedReason: reason
      )
    } catch {
      self.error = error.localizedDescription
    }
  }

  func logout() {
    isAuthenticated = false
  }
}
struct ContentView: View {
  @State private var bio = BiometricService()

  var body: some View {
    Group {
      if bio.isAuthenticated {
        SecretView()
      } else {
        VStack {
          if bio.biometryType == .faceID {
            Image(systemName: "faceid").font(.largeTitle)
          }
          Button("Authenticate") {
            Task { await bio.authenticate() }
          }
        }
      }
    }
    .onAppear { bio.checkAvailability() }
  }
}

Testing

Simulator

Xcode provides a simulator Face ID menu: Simulator > Face ID > Enrolled / Not Enrolled. Toggle enrollment state to test different paths.

  • Face ID enrolled: Use Match / Non-matching Face to test success and failure.
  • Touch ID: Same menu, works with Mac trackpad.

Do not bypass authentication for every Simulator build. For UI tests, inject a mock authenticator or gate a deterministic test double behind an explicit UI-test launch argument in Debug builds.

Unit testing

Abstract LAContext behind a protocol for testability:

protocol BiometryEvaluating {
  func canEvaluatePolicy(_ policy: LAPolicy, error: NSErrorPointer) -> Bool
  func evaluatePolicy(_ policy: LAPolicy, localizedReason: String) async throws -> Bool
  var biometryType: LABiometryType { get }
}

extension LAContext: BiometryEvaluating {}

Then inject a mock that returns .touchID and throws .authenticationFailed on demand.

Newer LocalAuthentication APIs

Current SDKs also expose APIs such as LARight, LARightStore, LAEnvironment, and LocalAuthenticationView. They support richer authorization and persisted-secret workflows on newer OS versions. LAContext remains the practical baseline when your app supports older iOS releases or uses existing Keychain access-control flows.

Best Practices

  • Use canEvaluatePolicy to check availability, but still handle evaluation failures. System state can change between calls.
  • Use deviceOwnerAuthentication by default. It provides the passcode escalation path.
  • Don’t treat biometrics as the only authentication factor. Provide a password-based fallback.
  • Set localizedCancelTitle to something actionable (“Use Password”) rather than just “Cancel.”
  • Create a fresh LAContext per authentication attempt. Reusing an invalidated context or one from a previous session will cause errors.
  • Check biometryType to adapt UI — show matching icons and text without assuming every device uses Face ID or Touch ID.
  • Store secrets in the Keychain with .biometryCurrentSet rather than evaluatePolicy alone for defense in depth.