Skip to content
ExponaBLOG
SWIFT, SWIFTUI & IOS APPS · DAY 041 / 100

Face ID and Touch ID: a Swift & iOS guide for developers

Biometric unlock with LocalAuthentication.

Expona Admin9 min read

Opening

Biometric authentication has become the standard user expectation on modern iOS devices, and developers who skip it risk friction during onboarding and login flows. This guide covers Face ID and Touch ID: a Swift & iOS guide for developers, walking you through the LocalAuthentication framework with concrete patterns we use in production e-commerce and SaaS applications.

Why biometric authentication matters

Touch ID arrived in 2013, Face ID in 2017, and today users are frustrated when apps force them to type passwords. Beyond UX, biometrics reduce support tickets from forgotten passwords and lower fraud exposure by keeping credentials off the network. We've seen conversion lift of 3–5% just by adding frictionless biometric unlock to checkout flows.

The LocalAuthentication framework is Apple's standard. It abstracts the device hardware and gives your app a clean API for evaluating biometric policy without ever touching the actual fingerprint or facial data—Apple keeps that locked in the Secure Enclave.

LAContext and evaluatePolicy

The entry point is LAContext. You create one, call evaluatePolicy, and handle the result. Here's the foundation:

swift
import LocalAuthentication

func authenticateWithBiometric(completion: @escaping (Result<Void, Error>) -> Void) {
    let context = LAContext()
    var error: NSError?
    
    // Check if biometry is available on this device
    guard context.canEvaluatePolicy(.deviceOwnerAuthenticationWithBiometrics, error: &error) else {
        completion(.failure(error ?? NSError(domain: "BiometricError", code: -1)))
        return
    }
    
    // Attempt biometric unlock
    context.evaluatePolicy(
        .deviceOwnerAuthenticationWithBiometrics,
        localizedReason: "Unlock your account to continue"
    ) { success, error in
        DispatchQueue.main.async {
            if success {
                completion(.success(()))
            } else {
                completion(.failure(error ?? NSError(domain: "BiometricError", code: -1)))
            }
        }
    }
}

The canEvaluatePolicy check is crucial—it tells you whether the device has biometry enabled and whether the user has enrolled. On a simulator without biometry or on a device where Face ID is disabled in Settings, this returns false. Always check before calling evaluatePolicy, or your app will hang waiting for a response that never comes.

The localizedReason string appears on the biometric prompt. Keep it short and clear. We've seen users cancel because they didn't understand why we were asking for their face or fingerprint.

In production, we wrap this in a utility:

swift
enum BiometricAuthError: LocalizedError {
    case notAvailable
    case userCancelled
    case userFallback
    case biometryLockedOut
    case other(Error)
    
    var errorDescription: String? {
        switch self {
        case .notAvailable:
            return "Face ID or Touch ID is not available on this device"
        case .userCancelled:
            return "Authentication was cancelled"
        case .userFallback:
            return "User selected passcode fallback"
        case .biometryLockedOut:
            return "Too many failed attempts. Use passcode."
        case .other(let error):
            return error.localizedDescription
        }
    }
}

class BiometricAuthenticator {
    static let shared = BiometricAuthenticator()
    
    func authenticate(reason: String, completion: @escaping (Result<Void, BiometricAuthError>) -> Void) {
        let context = LAContext()
        context.localizedFallbackTitle = "Use Passcode"
        
        var error: NSError?
        guard context.canEvaluatePolicy(.deviceOwnerAuthenticationWithBiometrics, error: &error) else {
            if let nsError = error as NSError? {
                if nsError.code == LAError.biometryLockedOut.rawValue {
                    completion(.failure(.biometryLockedOut))
                    return
                }
            }
            completion(.failure(.notAvailable))
            return
        }
        
        context.evaluatePolicy(.deviceOwnerAuthenticationWithBiometrics, localizedReason: reason) { success, error in
            DispatchQueue.main.async {
                if success {
                    completion(.success(()))
                } else if let laError = error as? LAError {
                    switch laError.code {
                    case .userCancel:
                        completion(.failure(.userCancelled))
                    case .userFallback:
                        completion(.failure(.userFallback))
                    case .biometryLockedOut:
                        completion(.failure(.biometryLockedOut))
                    default:
                        completion(.failure(.other(laError)))
                    }
                } else {
                    completion(.failure(.other(error ?? NSError(domain: "Unknown", code: -1))))
                }
            }
        }
    }
}

NSFaceIDUsageDescription in Info.plist

iOS requires you to declare your intent to use Face ID in the app's Info.plist. Without it, your app will crash when you call evaluatePolicy. This is Apple's privacy gate.

Add both of these keys:

swift
<key>NSFaceIDUsageDescription</key>
<string>We use Face ID to securely unlock your account</string>
<key>NSBiometryUsageDescription</key>
<string>We use your fingerprint or face to securely unlock your account</string>

NSFaceIDUsageDescription is for Face ID on iPhone X and later. NSBiometryUsageDescription covers both Face ID and Touch ID generically and is the preferred key for new apps. The string you provide appears in the permission prompt the first time the user attempts biometric authentication.

In Xcode, you can edit this in the Info tab under your target, or edit Info.plist directly. We recommend the source code editor to avoid conflicts when merging with teammates.

Key Purpose Required
NSBiometryUsageDescription Generic biometric usage (Face ID or Touch ID) Yes, for new apps
NSFaceIDUsageDescription Face ID specific (legacy, still checked on older OS) Recommended for compatibility

Fallback to passcode

Not every user has biometry enrolled, and biometry can fail—lockouts happen, environmental issues arise. You must always provide a passcode fallback. The LAContext.localizedFallbackTitle property controls the text on the fallback button. Set it to guide the user:

swift
func authenticateWithFallback(completion: @escaping (Result<Void, Error>) -> Void) {
    let context = LAContext()
    context.localizedFallbackTitle = "Use Account Password"
    
    var error: NSError?
    
    // If biometry is not available, allow passcode via deviceOwnerAuthentication
    if !context.canEvaluatePolicy(.deviceOwnerAuthenticationWithBiometrics, error: &error) {
        let passcodeContext = LAContext()
        passcodeContext.evaluatePolicy(
            .deviceOwnerAuthentication,
            localizedReason: "Unlock with your device passcode"
        ) { success, _ in
            DispatchQueue.main.async {
                completion(success ? .success(()) : .failure(NSError(domain: "PasscodeError", code: -1)))
            }
        }
        return
    }
    
    // Try biometric first
    context.evaluatePolicy(.deviceOwnerAuthenticationWithBiometrics, localizedReason: "Unlock your account") { success, error in
        DispatchQueue.main.async {
            if success {
                completion(.success(()))
            } else if let laError = error as? LAError, laError.code == .userFallback {
                // User tapped the fallback button; now prompt for passcode
                let passcodeContext = LAContext()
                passcodeContext.evaluatePolicy(
                    .deviceOwnerAuthentication,
                    localizedReason: "Unlock with your device passcode"
                ) { passcodeSuccess, _ in
                    DispatchQueue.main.async {
                        completion(passcodeSuccess ? .success(()) : .failure(NSError(domain: "PasscodeError", code: -1)))
                    }
                }
            } else {
                completion(.failure(error ?? NSError(domain: "BiometricError", code: -1)))
            }
        }
    }
}

There are two policies: .deviceOwnerAuthenticationWithBiometrics (biometric only) and .deviceOwnerAuthentication (biometric + passcode). We use biometric-only first for a smooth flow, then fall back to the full authentication prompt if the user chooses passcode or if biometry is locked out.

Protect Keychain items with biometry

Biometric unlock is most powerful when combined with Keychain. You can store tokens, credentials, and secrets in Keychain and require biometric or passcode authentication to access them. This ensures even if someone gains physical access to the device, they can't extract your credentials without passing biometric gates.

Use SecAccessControl to bind a Keychain item to biometry:

swift
import Security

func storeTokenInKeychain(token: String, account: String) throws {
    let query: [String: Any] = [
        kSecClass as String: kSecClassGenericPassword,
        kSecAttrAccount as String: account,
        kSecAttrService as String: "com.yourapp.auth",
        kSecValueData as String: token.data(using: .utf8)!,
        kSecAttrAccessible as String: kSecAttrAccessibleWhenPasscodeSetThisDeviceOnly
    ]
    
    // Create access control requiring biometry
    var error: Unmanaged<CFError>?
    guard let accessControl = SecAccessControlCreateWithFlags(
        kCFAllocatorDefault,
        kSecAttrAccessibleWhenPasscodeSetThisDeviceOnly,
        .biometryCurrentSet,
        &error
    ) else {
        throw error?.takeRetainedValue() as? Error ?? NSError(domain: "KeychainError", code: -1)
    }
    
    var keychainQuery: [String: Any] = [
        kSecClass as String: kSecClassGenericPassword,
        kSecAttrAccount as String: account,
        kSecAttrService as String: "com.yourapp.auth",
        kSecValueData as String: token.data(using: .utf8)!,
        kSecAttrAccessControl as String: accessControl
    ]
    
    // Delete existing item if present
    SecItemDelete(keychainQuery as CFDictionary)
    
    // Add the new item
    let status = SecItemAdd(keychainQuery as CFDictionary, nil)
    guard status == errSecSuccess else {
        throw NSError(domain: "KeychainError", code: Int(status))
    }
}

func retrieveTokenFromKeychain(account: String) throws -> String {
    let query: [String: Any] = [
        kSecClass as String: kSecClassGenericPassword,
        kSecAttrAccount as String: account,
        kSecAttrService as String: "com.yourapp.auth",
        kSecReturnData as String: true,
        kSecUseOperationPrompt as String: "Authenticate to retrieve your token"
    ]
    
    var result: CFTypeRef?
    let status = SecItemCopyMatching(query as CFDictionary, &result)
    
    guard status == errSecSuccess, let data = result as? Data, let token = String(data: data, encoding: .utf8) else {
        throw NSError(domain: "KeychainError", code: Int(status))
    }
    
    return token
}

The .biometryCurrentSet flag means the item is accessible only with the currently enrolled biometry. If the user adds a new fingerprint or enrolls a new face, access is denied—this prevents unauthorized access after biometric enrollment changes. The kSecUseOperationPrompt in the retrieval query triggers biometric authentication automatically. The OS handles the prompt; your code never sees the biometric data.

This pattern is crucial for storing refresh tokens and API keys. Store short-lived access tokens in memory; store long-lived refresh tokens in biometry-protected Keychain items.

Common mistakes

Not checking canEvaluatePolicy: Calling evaluatePolicy without checking canEvaluatePolicy first will hang or fail silently on devices without biometry. Always guard.

Forgetting Info.plist keys: Missing NSBiometryUsageDescription causes a crash, not a permission prompt. Test on a real device; the simulator may not enforce this as strictly.

Blocking the main thread: LocalAuthentication callbacks run on arbitrary threads. Always dispatch back to the main thread before updating UI. Use DispatchQueue.main.async in every completion handler.

Ignoring lockout errors: After five failed biometric attempts, the system locks out biometry temporarily. Detect LAError.biometryLockedOut and guide users to passcode or a different authentication method rather than retrying endlessly.

Storing secrets in plain Keychain: Keychain without access control is better than UserDefaults, but not by much. Always use SecAccessControl with .biometryCurrentSet for sensitive tokens. This adds a layer the attacker must pass.

Key takeaways

  • Use LAContext and evaluatePolicy with .deviceOwnerAuthenticationWithBiometrics as your primary biometric flow.
  • Always check canEvaluatePolicy before calling evaluatePolicy to avoid hangs on unsupported devices.
  • Add both NSBiometryUsageDescription and NSFaceIDUsageDescription to your Info.plist or the app will crash.
  • Provide a passcode fallback by detecting the .userFallback error and prompting with .deviceOwnerAuthentication.
  • Protect sensitive credentials in Keychain with SecAccessControl and .biometryCurrentSet to enforce biometric unlock on retrieval.
  • Always dispatch biometric completion handlers back to the main thread before updating UI.
  • Handle biometryLockedOut gracefully; guide users to alternative authentication methods instead of retrying.
ShareXLinkedInReddit

Found this useful? Support the writing.

Buy me a coffee
Written by
Expona Admin
Site administrator

Comments

Loading comments…

Leave a comment

Comments are reviewed before they appear. Be kind and stay on topic.

Related articles