LocalAuthentication in SwiftUI (iOS 26): Face ID and Touch ID Complete Guide
How to add Face ID, Touch ID, and Optic ID to a SwiftUI app on iOS 26 using LocalAuthentication, with LAPolicy tips, LAError handling, and Keychain binding for secrets.
The LocalAuthentication framework lets a SwiftUI app in iOS 26 unlock sensitive UI with Face ID, Touch ID, or Optic ID by creating an LAContext, calling canEvaluatePolicy(_:error:) to check availability, then awaiting evaluatePolicy(_:localizedReason:) to run the biometric prompt. Nothing else in your project needs to know a face was scanned. The framework hands you a Bool (or throws LAError), and the biometric bits never leave the Secure Enclave. So, in this guide I'll walk through every LAPolicy option, the LAError codes you'll actually see in production, how to bind biometrics to a Keychain item so a stolen device can't dump your data, and the re-authentication pattern I use whenever the scene comes back to the foreground.
LAContext is the single object you create per authentication attempt. Reuse it for follow-up calls in the same "session", but throw it away and make a new one after a background trip.
The two policies that matter: .deviceOwnerAuthenticationWithBiometrics requires Face ID / Touch ID / Optic ID; .deviceOwnerAuthentication falls back to device passcode after a biometric failure.
biometryType is only meaningful after a successful canEvaluatePolicy call. Read it too early, and you'll get .none even on a Face ID phone.
You must ship an NSFaceIDUsageDescription string in Info.plist, or the first Face ID prompt will crash the app instantly. Touch ID and Optic ID don't need their own key.
Binding biometrics to a Keychain item with SecAccessControlCreateWithFlags and .biometryCurrentSet means the OS runs the prompt for you, and the item invalidates the second the user re-enrolls a face.
Re-authenticate on scenePhase == .active after a threshold (I use 60 seconds) instead of on every foregrounding, or users will hate you.
What is LocalAuthentication in SwiftUI?
LocalAuthentication is the framework Apple ships with every version of iOS, iPadOS, macOS, and visionOS for evaluating whether the person holding the device is the enrolled owner, using biometrics or the device passcode. In SwiftUI you don't get a dedicated view. Instead, you wrap the imperative LAContext API in an async method, expose the result through @State or an @Observable view model, and gate your protected UI behind it. That's it. The framework doesn't render anything on your behalf (the Face ID sheet and Touch ID sensor overlay are drawn by the OS in a separate process), so you can't style them, and you can't intercept them.
What LocalAuthentication is not is a session manager. It doesn't remember that the user unlocked five minutes ago, it doesn't tie into your backend, and it doesn't store credentials. If you want a "keep me signed in for 30 days" behaviour, that lives in Keychain (usually with a biometric-bound access control flag) and your own state. LocalAuthentication just tells you, right now, in this instant, whether the device owner is present. Treat it like a bouncer at a door, not a session cookie.
In iOS 26 the framework picked up two quiet but useful additions. First, LAContext.biometryType now returns .opticID on Vision Pro when you build for visionOS 2, so the same code path handles all three modalities. Second, the framework fixed a long-standing bug where a rapid double-invocation would sometimes resolve both prompts with .userCancel. If your legacy code has a workaround for that with a debounce, you can delete it. (I shipped exactly that debounce in a banking app last year and was quietly relieved to rip it out.)
Info.plist setup and the NSFaceIDUsageDescription crash
Before you write a single line of Swift, add NSFaceIDUsageDescription to your Info.plist (or your target's Info tab in Xcode 26). This is a short user-facing string that appears in the very first Face ID prompt the app ever shows, and again in Settings > Privacy & Security > Face ID & Passcode next to your app name. If you forget this key, the first evaluatePolicy call will not fail gracefully. iOS will kill your process with a NSInvalidArgumentException and a message about the missing key in the console. Honestly, this is one of the most reported "why did my app crash on launch" issues on the Apple Developer forums, and I've hit it myself on a fresh project more than once.
Good copy is short, specific, and states the value to the user. Not "We use Face ID to authenticate you" (they know, they just saw the sheet). Something like "Face ID unlocks your saved cards and private notes" is much better. Touch ID does not require its own key (the same string is used for both), and Optic ID on visionOS also reads from NSFaceIDUsageDescription.
<!-- Info.plist -->
<key>NSFaceIDUsageDescription</key>
<string>Face ID unlocks your saved cards and private notes.</string>
You don't need an entitlement for basic biometric evaluation. The Keychain-bound flow described below also needs no special entitlement, unless you also enable Keychain Sharing, which is a separate concern. See Apple's LocalAuthentication framework documentation for the canonical list of required keys and edge cases when your app also uses the SharedWebCredentials or ASAuthorization APIs.
How do I add Face ID to a SwiftUI app?
The minimum viable Face ID gate is roughly forty lines of Swift and one @Observable class. Below is the pattern I ship in every app that has a "private" surface: a small authentication controller, a SwiftUI view that reads its isUnlocked flag, and a button that kicks off the prompt. Everything is async, so it composes cleanly with the rest of your Task-based code.
import LocalAuthentication
import Observation
@Observable
final class BiometricAuthController {
enum State { case locked, authenticating, unlocked, failed(LAError) }
var state: State = .locked
private var context = LAContext()
func authenticate(reason: String) async {
state = .authenticating
context = LAContext() // fresh context per attempt
var error: NSError?
guard context.canEvaluatePolicy(.deviceOwnerAuthenticationWithBiometrics,
error: &error) else {
state = .failed(LAError(_nsError: error ?? NSError(domain: LAErrorDomain,
code: LAError.biometryNotAvailable.rawValue)))
return
}
do {
let ok = try await context.evaluatePolicy(.deviceOwnerAuthenticationWithBiometrics,
localizedReason: reason)
state = ok ? .unlocked : .locked
} catch let error as LAError {
state = .failed(error)
} catch {
state = .locked
}
}
}
The SwiftUI side is deliberately boring. All of the biometric logic lives in the controller, so the view just renders whatever state is currently showing.
struct PrivateVaultView: View {
@State private var auth = BiometricAuthController()
var body: some View {
Group {
switch auth.state {
case .locked:
LockedView { await auth.authenticate(reason: "Unlock your vault") }
case .authenticating:
ProgressView("Authenticating\u{2026}")
case .unlocked:
VaultContentsView()
case .failed(let error):
ErrorView(error: error) {
await auth.authenticate(reason: "Unlock your vault")
}
}
}
.task { await auth.authenticate(reason: "Unlock your vault") }
}
}
Two details that matter. First, I always create a fresh LAContext per attempt. A stale context caches the result of the last canEvaluatePolicy call, which is the source of a lot of "Face ID doesn't work the second time" bug reports. I hit this exact bug shipping a 2FA feature in 2024 and spent an afternoon chasing it before I realized the context was the culprit. Second, the localizedReason argument is the string shown to the user below the Face ID glyph on iPhone with Touch ID and inside the Face ID sheet on Face ID devices. Write it in the imperative and in the user's language. See our String Catalogs in Xcode 26 guide if you need this text localized in more than a handful of languages.
LAPolicy differences: with biometrics vs device owner
There are three policies you can pass to evaluatePolicy, and picking the right one shapes your entire fallback UX.
Policy
Accepts biometrics
Falls back to passcode
Typical use
.deviceOwnerAuthenticationWithBiometrics
Yes (Face ID / Touch ID / Optic ID)
No, throws .userFallback or .biometryLockout
App unlock, sensitive read
.deviceOwnerAuthentication
Yes
Yes, automatic passcode sheet
Payment confirm, delete-forever
.deviceOwnerAuthenticationWithCompanion (iOS 18+)
Yes, plus paired Apple Watch
Yes
Mac apps that trust the watch
Choose .deviceOwnerAuthenticationWithBiometrics when biometric failure should keep the app locked (a password vault, a banking dashboard, a private journal). Choose .deviceOwnerAuthentication when the passcode is an acceptable equivalent (a purchase confirmation, deleting a note, revealing a saved credential). If in doubt, go with the biometrics-only variant and offer a manual "Sign out and use my password" button as your fallback. The passcode-fallback sheet feels more heavyweight than users expect, and can be a friction point for the very quick "unlock the vault to peek at one card" flow.
The companion variant is niche and only useful when your app also targets macOS with a paired Apple Watch. On iOS 26 it evaluates identically to .deviceOwnerAuthenticationWithBiometrics, so you can pass it universally without a special code branch.
You'll want to know which biometric hardware the device has for two reasons: to render the right icon in your unlock button (a face silhouette on Face ID, a fingerprint on Touch ID), and to write copy that says "Unlock with Face ID" rather than "Unlock with biometrics". The property is LAContext.biometryType, and it's an enum with cases .none, .faceID, .touchID, and .opticID.
The trap: biometryType is only populated after you've called canEvaluatePolicy. If you read it before that call, you'll get .none on every device, including a shiny new iPhone 17 Pro. The reason is that the framework doesn't want to leak biometric hardware information to processes that haven't declared the intent to use it. So the discovery flow is: create a context, call canEvaluatePolicy, then read biometryType.
enum Biometry { case none, faceID, touchID, opticID, unavailable(LAError) }
func detectBiometry() -> Biometry {
let context = LAContext()
var error: NSError?
guard context.canEvaluatePolicy(.deviceOwnerAuthenticationWithBiometrics,
error: &error) else {
return .unavailable(LAError(_nsError: error ?? NSError(domain: LAErrorDomain, code: -1)))
}
switch context.biometryType {
case .faceID: return .faceID
case .touchID: return .touchID
case .opticID: return .opticID
case .none: return .none
@unknown default: return .none
}
}
Use this at the top of your unlock view to pick a label and icon. A common pattern is to store the result in an @Observable app-scope model, so every screen that shows a biometric CTA reads the same copy.
Handling every LAError code that reaches production
The LAError enum has fifteen-plus cases, but only a handful actually appear in production crash logs. Here's the shortlist, and what to do about each. Anything not on this list you can lump into a generic "try again" path.
.userCancel: the user tapped the Cancel button on the Face ID sheet. Do nothing. Don't retry. Don't show an error. Silence is the correct UX.
.userFallback: the user tapped Enter Passcode during a biometrics-only prompt. Show your manual login screen if you have one; otherwise re-prompt.
.systemCancel: the OS cancelled the sheet, typically because another authentication came in or the app was backgrounded. Retry once when you return to .active.
.authenticationFailed: the biometric was scanned but didn't match. iOS gives the user two more attempts inside the sheet before it throws. When you see this, biometrics are already exhausted, so surface a manual sign-in path.
.biometryNotEnrolled: no fingerprints or face on file. Point the user at Settings > Face ID & Passcode. Do not spam-retry.
.biometryLockout: five failed attempts locked the biometric until a passcode entry. Re-run with .deviceOwnerAuthentication to prompt for passcode and unlock the biometric.
.biometryNotAvailable: hardware missing, disabled in parental controls, or the user denied your app's Face ID permission. Read NSFaceIDUsageDescription logs to distinguish; for a permission denial, deep-link to Settings.
.passcodeNotSet: the device has no passcode. Nothing you can do beyond a nudge; biometrics require a passcode of last resort.
Wrap them all in one method, so your views don't have to remember. When something logs, use structured logging from our OSLog and Logger privacy guide, so you don't accidentally leak the reason string into an unprivileged log.
Binding Face ID to a Keychain item with SecAccessControl
The strongest way to protect a secret is to store it in Keychain with an access control that requires biometrics to read. The OS runs the Face ID prompt for you inside SecItemCopyMatching, so if the biometric fails, the read fails and the token never enters your process memory. This is how 1Password, banking apps, and Apple's own Wallet secure their most sensitive items.
import Security
import LocalAuthentication
enum KeychainBiometric {
static func save(_ token: Data, service: String, account: String) throws {
var error: Unmanaged<CFError>?
guard let access = SecAccessControlCreateWithFlags(
nil,
kSecAttrAccessibleWhenPasscodeSetThisDeviceOnly,
.biometryCurrentSet, // invalidate if a new face is enrolled
&error
) else { throw error!.takeRetainedValue() as Error }
let attrs: [String: Any] = [
kSecClass as String: kSecClassGenericPassword,
kSecAttrService as String: service,
kSecAttrAccount as String: account,
kSecValueData as String: token,
kSecAttrAccessControl as String: access
]
SecItemDelete(attrs as CFDictionary) // idempotent overwrite
let status = SecItemAdd(attrs as CFDictionary, nil)
guard status == errSecSuccess else { throw NSError(domain: NSOSStatusErrorDomain, code: Int(status)) }
}
static func read(service: String, account: String, reason: String) async 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,
kSecUseAuthenticationContext as String: context
]
var out: CFTypeRef?
let status = SecItemCopyMatching(query as CFDictionary, &out)
guard status == errSecSuccess, let data = out as? Data else {
throw NSError(domain: NSOSStatusErrorDomain, code: Int(status))
}
return data
}
}
Two flags to understand. kSecAttrAccessibleWhenPasscodeSetThisDeviceOnly makes the item inaccessible if the user removes their passcode, and prevents the item from syncing to iCloud or restoring to a new device. That's exactly what you want for a biometric-bound secret. .biometryCurrentSet is stricter than .biometryAny: if the user later adds a new face to Face ID, the item invalidates, and reads throw errSecItemNotFound. This is the correct default for anything financial. If you want the item to survive re-enrollment, use .biometryAny. For deeper coverage of the Keychain API surface itself, see our guide to Keychain Services in Swift.
Re-authenticating when the app returns from background
The single most common LocalAuthentication design mistake I see is running the biometric prompt every time the app comes back to the foreground. Users switch out for a Slack notification, come back three seconds later, and get a Face ID sheet. That's punishment, not security. The fix is to gate on time: if the app was backgrounded for less than, say, 60 seconds, keep the unlocked state; if longer, re-lock and re-authenticate.
@Observable
final class AppLockController {
var isUnlocked = false
private var backgroundedAt: Date?
private let graceInterval: TimeInterval = 60
func handleScenePhase(_ phase: ScenePhase) {
switch phase {
case .background, .inactive:
if backgroundedAt == nil { backgroundedAt = Date() }
case .active:
if let ts = backgroundedAt, Date().timeIntervalSince(ts) > graceInterval {
isUnlocked = false
}
backgroundedAt = nil
@unknown default: break
}
}
}
struct RootView: View {
@State private var lock = AppLockController()
@Environment(\.scenePhase) private var scenePhase
var body: some View {
Group {
if lock.isUnlocked {
MainAppView()
} else {
LockScreenView(onUnlock: { lock.isUnlocked = true })
}
}
.onChange(of: scenePhase) { _, new in
lock.handleScenePhase(new)
}
}
}
Tune graceInterval to your risk profile. A password manager might want zero seconds, where every foreground re-locks. A note-taking app with a "private notes" section might use 5 minutes. Anything above 15 minutes stops being a meaningful security control, since the phone itself will already have auto-locked. If your app also renders sensitive info in the app switcher preview, cover the view with a blurred placeholder when scenePhase == .inactive, otherwise the last frame is stored plaintext in the switcher snapshot and viewable without any authentication at all. This trick pairs well with the modifier from our reusable glass effect component guide.
Testing Face ID in the iOS 26 simulator
Face ID works in the iOS 26 Simulator, but only if you enrol it first. Boot a simulator with Face ID hardware (iPhone 12 or newer profile), then in the menu bar: Features > Face ID > Enrolled. Once enrolled, the same menu gives you Matching Face and Non-matching Face to simulate a pass or a fail. Touch ID has the equivalent under Features > Touch ID. Optic ID is available in the visionOS simulator via a similar menu.
What the simulator will not do: honour .biometryLockout after five failed attempts, or expire a .biometryCurrentSet Keychain item on re-enrollment. Both behaviours are implemented by the Secure Enclave, which the simulator doesn't emulate. If you need to test the lockout branch, use a physical device and deliberately fail five times, or inject the error into your controller via a debug hook in #if DEBUG.
For UI tests, the Xcode 26 XCUIApplication API lets you drive the simulator's biometric menu via XCUIDevice.shared.perform(.matchingFace) and .nonMatchingFace. That covers most of what you need for unlock and re-auth screens without shelling out to xcrun simctl. Refer to Apple's XCTest testing framework reference for the full method list. If you'd rather keep the auth controller as a pure protocol and inject a mock in tests, our complete Swift Testing guide covers the test-double patterns I use for exactly this shape of dependency.
Frequently Asked Questions
Why does my SwiftUI app crash the first time it calls Face ID?
You're missing the NSFaceIDUsageDescription string in Info.plist. iOS 26 will terminate the process with an NSInvalidArgumentException the instant evaluatePolicy tries to render the sheet without a usage description. Add the key, ship a short user-facing string, and rebuild. Touch ID and Optic ID also read from this key.
What's the difference between deviceOwnerAuthentication and deviceOwnerAuthenticationWithBiometrics?
Both accept Face ID, Touch ID, or Optic ID. .deviceOwnerAuthentication automatically falls back to a passcode sheet if biometrics fail or aren't enrolled, while .deviceOwnerAuthenticationWithBiometrics throws .userFallback or .biometryLockout and forces you to design your own recovery path. Use the biometrics-only policy for app unlock and the fallback-friendly policy for one-off confirmations like payments.
How do I check if Face ID or Touch ID is available on the device?
Create an LAContext, call canEvaluatePolicy(.deviceOwnerAuthenticationWithBiometrics, error:), and only if that returned true, read context.biometryType. Reading biometryType before the policy check always returns .none, which is the source of a lot of "Face ID is broken" bug reports.
Can I use Face ID with Keychain?
Yes, and it's stronger than gating a Keychain read on your own Face ID check. Create the item with SecAccessControlCreateWithFlags(nil, kSecAttrAccessibleWhenPasscodeSetThisDeviceOnly, .biometryCurrentSet, &error) and Keychain runs the biometric prompt for you inside SecItemCopyMatching. If the biometric fails, the read fails and the secret never enters your process.
Why doesn't Face ID work in the iOS Simulator?
It does, but you have to enrol first. Boot a Face-ID-capable simulator, then use the menu Features > Face ID > Enrolled. Afterwards Matching Face and Non-matching Face drive the pass/fail flows. The simulator does not emulate .biometryLockout or .biometryCurrentSet invalidation, so those branches need a physical device.
A hands-on guide to SwiftUI KeyframeAnimator in iOS 26: multi-track parametric keyframes, when to reach for it over PhaseAnimator, and how to sync motion with haptics.
Keychain Services is Apple's hardware-backed secure storage API for saving passwords, tokens, and cryptographic keys. This iOS 26 guide covers SecItemAdd, access control, biometric protection with LocalAuthentication, iCloud Keychain sync, and Secure Enclave keys with CryptoKit.
Master the SwiftUI toolbar in iOS 26. ToolbarItem placements, keyboard bars, Liquid Glass styling, VoiceOver, and cross-platform behavior on iPhone, iPad, and Mac.