How to implement biometric auth in SwiftUI
Create an LAContext, call
evaluatePolicy(.deviceOwnerAuthenticationWithBiometrics, localizedReason:)
on a background thread, then publish the result back to @MainActor to update your SwiftUI state.
Add NSFaceIDUsageDescription to Info.plist or the app will crash on Face ID devices.
import LocalAuthentication
import SwiftUI
struct LockView: View {
@State private var isUnlocked = false
@State private var errorMessage: String?
var body: some View {
VStack(spacing: 20) {
if isUnlocked {
Text("Welcome!").font(.largeTitle)
} else {
Button("Unlock with Face ID / Touch ID") {
authenticate()
}
}
if let msg = errorMessage { Text(msg).foregroundStyle(.red) }
}
}
private func authenticate() {
let context = LAContext()
var error: NSError?
guard context.canEvaluatePolicy(.deviceOwnerAuthenticationWithBiometrics,
error: &error) else {
errorMessage = error?.localizedDescription
return
}
context.evaluatePolicy(.deviceOwnerAuthenticationWithBiometrics,
localizedReason: "Unlock the app") { success, authError in
DispatchQueue.main.async {
isUnlocked = success
errorMessage = (authError as? LAError).map { mapLAError($0) }
}
}
}
}
Full implementation
The implementation below wraps LAContext inside an
@Observable class so authentication state is cleanly separated
from the view. The view reacts to published state changes, showing a locked or unlocked UI, a friendly error
banner, and a device-appropriate button label (Face ID vs Touch ID). A fallback to device passcode
(.deviceOwnerAuthentication) is also shown as a variant.
import LocalAuthentication
import SwiftUI
// MARK: - Auth model
@Observable
final class BiometricAuthModel {
var isUnlocked = false
var errorMessage: String?
var biometryType: LABiometryType = .none
private let context = LAContext()
init() {
// Populate biometryType before the first auth attempt
var err: NSError?
_ = context.canEvaluatePolicy(.deviceOwnerAuthenticationWithBiometrics, error: &err)
biometryType = context.biometryType
}
/// Returns true when the hardware + enrollment supports biometrics.
var isBiometryAvailable: Bool {
var err: NSError?
return LAContext().canEvaluatePolicy(
.deviceOwnerAuthenticationWithBiometrics, error: &err)
}
/// Trigger Face ID / Touch ID prompt.
func authenticate(reason: String = "Unlock the app") {
let ctx = LAContext()
var err: NSError?
guard ctx.canEvaluatePolicy(.deviceOwnerAuthenticationWithBiometrics, error: &err) else {
errorMessage = err.map { mapLAError($0) } ?? "Biometrics unavailable"
return
}
ctx.evaluatePolicy(.deviceOwnerAuthenticationWithBiometrics,
localizedReason: reason) { [weak self] success, authError in
guard let self else { return }
DispatchQueue.main.async {
if success {
self.isUnlocked = true
self.errorMessage = nil
} else if let laErr = authError as? LAError {
self.errorMessage = mapLAError(laErr)
}
}
}
}
func lock() {
isUnlocked = false
errorMessage = nil
}
}
// MARK: - Error mapping
private func mapLAError(_ error: LAError) -> String {
switch error.code {
case .authenticationFailed: return "Biometric did not match. Try again."
case .userCancel: return "Authentication cancelled."
case .userFallback: return "Please enter your passcode."
case .biometryNotEnrolled: return "No biometrics enrolled. Go to Settings."
case .biometryLockout: return "Too many attempts. Use your passcode."
case .biometryNotAvailable: return "Biometrics not available on this device."
default: return "Authentication failed (\(error.code.rawValue))."
}
}
// MARK: - View
struct BiometricAuthView: View {
@State private var auth = BiometricAuthModel()
var body: some View {
Group {
if auth.isUnlocked {
unlockedContent
} else {
lockedContent
}
}
.animation(.spring(duration: 0.35), value: auth.isUnlocked)
}
// MARK: Locked screen
private var lockedContent: some View {
VStack(spacing: 32) {
Spacer()
Image(systemName: auth.biometryType == .faceID ? "faceid" : "touchid")
.resizable()
.scaledToFit()
.frame(width: 72, height: 72)
.foregroundStyle(.primary)
.accessibilityHidden(true)
Text("App Locked")
.font(.title.bold())
if let msg = auth.errorMessage {
Text(msg)
.font(.footnote)
.foregroundStyle(.red)
.multilineTextAlignment(.center)
.padding(.horizontal)
.accessibilityLabel("Auth error: \(msg)")
}
Button(action: { auth.authenticate() }) {
Label(
auth.biometryType == .faceID ? "Unlock with Face ID" : "Unlock with Touch ID",
systemImage: auth.biometryType == .faceID ? "faceid" : "touchid"
)
.frame(maxWidth: .infinity)
.padding()
.background(.blue)
.foregroundStyle(.white)
.clipShape(RoundedRectangle(cornerRadius: 14))
}
.padding(.horizontal, 40)
.accessibilityLabel(auth.biometryType == .faceID
? "Unlock with Face ID"
: "Unlock with Touch ID")
Spacer()
}
.padding()
}
// MARK: Unlocked screen
private var unlockedContent: some View {
VStack(spacing: 24) {
Image(systemName: "checkmark.seal.fill")
.resizable()
.scaledToFit()
.frame(width: 64, height: 64)
.foregroundStyle(.green)
.accessibilityHidden(true)
Text("Welcome back!")
.font(.title.bold())
Button("Lock App", role: .destructive) {
auth.lock()
}
.buttonStyle(.borderedProminent)
}
.padding()
}
}
// MARK: - Preview
#Preview("Locked") {
BiometricAuthView()
}
#Preview("Unlocked") {
let m = BiometricAuthModel()
// Simulate unlocked state for preview
let view = BiometricAuthView()
return view
}
How it works
-
LAContext initialisation & biometry detection.
In
BiometricAuthModel.init(), a throwawaycanEvaluatePolicycall is made purely to populatecontext.biometryType(.faceID / .touchID / .none). This drives the button label and icon before the user taps anything. -
Policy evaluation on a background thread.
ctx.evaluatePolicy(_:localizedReason:reply:)always fires its completion handler on an internal background queue, so theDispatchQueue.main.asynchop is mandatory before mutating@Observableproperties. -
Error mapping via
LAError.Code. The free functionmapLAError(_:)switches on typed error codes such as.biometryLockoutand.userCancelto return human-readable strings instead of raw OS messages. -
Animated state transitions.
The
.animation(.spring, value: auth.isUnlocked)modifier on the top-levelGroupprovides a smooth cross-fade wheneverisUnlockedflips, with no extrawithAnimationcall needed. -
Accessibility labels.
The SF Symbol icons carry
accessibilityHidden(true)because the adjacent button label already announces the action. The error banner carries an explicitaccessibilityLabelprefixed with "Auth error:" so VoiceOver users understand the context immediately.
Variants
Fall back to device passcode
Replace .deviceOwnerAuthenticationWithBiometrics with
.deviceOwnerAuthentication to automatically offer the
device passcode when biometrics fail or are unavailable — no extra code required.
// In BiometricAuthModel.authenticate()
// Change the policy to allow passcode fallback:
ctx.evaluatePolicy(
.deviceOwnerAuthentication, // ← was .deviceOwnerAuthenticationWithBiometrics
localizedReason: "Unlock the app"
) { success, authError in
DispatchQueue.main.async {
self.isUnlocked = success
if let err = authError as? LAError {
self.errorMessage = mapLAError(err)
}
}
}
// canEvaluatePolicy also needs to match:
guard ctx.canEvaluatePolicy(.deviceOwnerAuthentication, error: &err) else { ... }
Re-authenticate on app foreground
Use scenePhase to lock the app whenever it returns to
the foreground, then immediately prompt for biometrics — a common pattern for banking and password-manager apps.
struct RootView: View {
@Environment(\.scenePhase) private var phase
@State private var auth = BiometricAuthModel()
var body: some View {
BiometricAuthView()
.environment(auth)
.onChange(of: phase) { _, newPhase in
if newPhase == .active && !auth.isUnlocked {
auth.authenticate(reason: "Re-authenticate to continue")
}
if newPhase == .background {
auth.lock() // lock immediately on background
}
}
}
}
Common pitfalls
-
⚠️ Missing NSFaceIDUsageDescription crashes on Face ID devices.
Add the key to
Info.plist(or the app's Info tab in Xcode) with a user-facing explanation string. Touch ID does not require a usage description, but Face ID does — the app will hard-crash at theevaluatePolicycall if it's missing. -
⚠️ Calling evaluatePolicy on the main thread blocks the UI.
evaluatePolicycan take several seconds while the biometric sensor samples. It calls back on a private background queue — never block on it or await it directly from@MainActorwithout aTask.detached. -
⚠️ Reusing the same LAContext after an error.
An
LAContextinstance cannot be reused after it returns an error or success. Always instantiate a freshlet ctx = LAContext()on every authentication attempt, as shown in the implementation above. -
⚠️ Simulator always returns failure for biometrics by default.
In Simulator go to Features → Face ID / Touch ID → Enrolled, then
Matching Face / Matching Touch to simulate success. CI pipelines should mock
LAContextvia a protocol for reliable tests.
Prompt this with Claude Code
When using Soarias or Claude Code directly to implement this:
Implement biometric auth in SwiftUI for iOS 17+. Use LocalAuthentication (LAContext, LAError, LABiometryType). Support Face ID and Touch ID with appropriate button labels. Fall back to device passcode on biometry lockout. Make it accessible (VoiceOver labels on all interactive elements). Add a #Preview with realistic sample data.
In Soarias, drop this prompt into the Build phase after scaffolding your screen list — the agent will wire up the auth gate as a reusable
@Observable model and inject it at the root of your navigation hierarchy automatically.
Related
FAQ
Does this work on iOS 16?
Yes — LAContext and
evaluatePolicy have been available since iOS 8.
The @Observable macro used in the model class requires
iOS 17+. To target iOS 16 swap @Observable for
ObservableObject +
@Published.
The rest of the code is backwards-compatible.
How do I unit-test LAContext without real hardware?
Define a protocol (BiometryProvider) with
canEvaluate and
evaluate methods, make
LAContext conform via an extension, and inject a mock
in tests. This lets you simulate success, failure, lockout, and cancellation deterministically without
touching the Simulator's biometrics toggle.
What is the UIKit equivalent?
LAContext is framework-agnostic — it is the same API
in both UIKit and SwiftUI apps. In UIKit you would call
evaluatePolicy from a
UIViewController and dispatch back to the main queue
to update your UI, exactly as shown here. There is no UIKit-specific biometric API.
Last reviewed: 2026-05-11 by the Soarias team.