Introduction

I was eager to learn about securing user data using Keychain and biometric authentication. Here are a few steps I found:

Caveats

You can test accessing Keychain data using Touch ID and Face ID only on a real device.

First Step

The first step is to add the Privacy - Face ID Usage Description key to your Info.plist. Without it, you would not be able to retrieve data from Keychain using Face ID.

Second Step

The second step would be to add the addCredentials method to be able to save user data to Keychain.

/// Stores credentials for the given server.
func addCredentials(_ credentials: Credentials, server: String) throws {
    // Use the username as the account, and get the password as data.
    let account = credentials.username
    let password = credentials.password.data(using: String.Encoding.utf8)!

    // Create an access control instance that dictates how the item can be read later.
    let access = SecAccessControlCreateWithFlags(nil, // Use the default allocator.
                                                 kSecAttrAccessibleWhenPasscodeSetThisDeviceOnly,
                                                 .userPresence,
                                                 nil) // Ignore any error.

    // Allow a device unlock in the last 10 seconds to be used to get at keychain items.
    let context = LAContext()
    context.touchIDAuthenticationAllowableReuseDuration = 10

    // Build the query for use in the add operation.
    let query: [String: Any] = [kSecClass as String: kSecClassInternetPassword,
                                kSecAttrAccount as String: account,
                                kSecAttrServer as String: server,
                                kSecAttrAccessControl as String: access as Any,
                                kSecUseAuthenticationContext as String: context,
                                kSecValueData as String: password]

    let status = SecItemAdd(query as CFDictionary, nil)
    guard status == errSecSuccess else { throw KeychainError(status: status) }
}

Third Step

The third step is to add the readCredentials method to be capable of retrieving user data from Keychain.

/// Reads the stored credentials for the given server.
func readCredentials(server: String) throws -> Credentials {
    let context = LAContext()
    context.localizedReason = "Access your password on the keychain"
    let query: [String: Any] = [kSecClass as String: kSecClassInternetPassword,
                                kSecAttrServer as String: server,
                                kSecMatchLimit as String: kSecMatchLimitOne,
                                kSecReturnAttributes as String: true,
                                kSecUseAuthenticationContext as String: context,
                                kSecReturnData as String: true]

    var item: CFTypeRef?
    let status = SecItemCopyMatching(query as CFDictionary, &item)
    guard status == errSecSuccess else { throw KeychainError(status: status) }

    guard let existingItem = item as? [String: Any],
          let passwordData = existingItem[kSecValueData as String] as? Data,
          let password = String(data: passwordData, encoding: String.Encoding.utf8),
          let account = existingItem[kSecAttrAccount as String] as? String
    else {
        throw KeychainError(status: errSecInternalError)
    }

    return Credentials(username: account, password: password)
}

Fourth Step

The fourth step is to add the deleteCredentials method to have the ability to delete user data from Keychain.

/// Deletes credentials for the given server.
func deleteCredentials(server: String) throws {
    let query: [String: Any] = [kSecClass as String: kSecClassInternetPassword,
                                kSecAttrServer as String: server]

    let status = SecItemDelete(query as CFDictionary)
    guard status == errSecSuccess else { throw KeychainError(status: status) }
}

UI

import SwiftUI
import LocalAuthentication

struct ContentView: View {

    @State private var status: String = ""

    var body: some View {
        VStack {
            ForEach(Command.allCases) { command in
                Button(command.rawValue) {
                    switch command {
                    case .add:
                        // Normally, username and password would come from the user interface.
                        let credentials = Credentials(username: "appleseed", password: "1234")
                        do {
                            try addCredentials(credentials, server: server)
                            status = statusMessage(.add, nil)
                        } catch {
                            status = error.localizedDescription
                        }
                    case .read:
                        do {
                            status = statusMessage(.read, try readCredentials(server: server))
                        } catch {
                            status = error.localizedDescription
                        }
                    case .delete:
                        do {
                            try deleteCredentials(server: server)
                            status = statusMessage(.delete, nil)
                        } catch {
                            status = error.localizedDescription
                        }
                    }
                }
                if command != .delete {
                    Spacer()
                }
            }
            Spacer()
            Text(status)
        }
    }

}

Helpers

enum Command: String, CaseIterable, Identifiable {

    var id: String { rawValue }

    case add
    case read
    case delete

}

/// The username and password that we want to store or read.
struct Credentials {
    var username: String
    var password: String
}

/// Keychain errors we might encounter.
struct KeychainError: Error {
    var status: OSStatus

    var localizedDescription: String {
        return SecCopyErrorMessageString(status, nil) as String? ?? "Unknown error."
    }
}

/// The server we are accessing with the credentials.
let server = "www.example.com"

func statusMessage(_ command: Command, _ credentials: Credentials? = nil) -> String {
    switch command {
    case .add:
        return "Added credentials."
    case .read:
        return "Read credentials: \(credentials!.username)/\(credentials!.password)"
    case .delete:
        return "Deleted credentials."
    }
}

You can find more detailed information and project details in the Apple Developer Documentation.

Thank you for reading! 😊