```html SwiftUI: How to Implement Biometric Auth (iOS 17+, 2026)

How to implement biometric auth in SwiftUI

iOS 17+ Xcode 16+ Intermediate APIs: LocalAuthentication Updated: May 11, 2026
TL;DR

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

  1. LAContext initialisation & biometry detection. In BiometricAuthModel.init(), a throwaway canEvaluatePolicy call is made purely to populate context.biometryType (.faceID / .touchID / .none). This drives the button label and icon before the user taps anything.
  2. Policy evaluation on a background thread. ctx.evaluatePolicy(_:localizedReason:reply:) always fires its completion handler on an internal background queue, so the DispatchQueue.main.async hop is mandatory before mutating @Observable properties.
  3. Error mapping via LAError.Code. The free function mapLAError(_:) switches on typed error codes such as .biometryLockout and .userCancel to return human-readable strings instead of raw OS messages.
  4. Animated state transitions. The .animation(.spring, value: auth.isUnlocked) modifier on the top-level Group provides a smooth cross-fade whenever isUnlocked flips, with no extra withAnimation call needed.
  5. Accessibility labels. The SF Symbol icons carry accessibilityHidden(true) because the adjacent button label already announces the action. The error banner carries an explicit accessibilityLabel prefixed 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

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.

```