See Part 1 for basics of Passkeys.
Here's the code. Three main pieces: view model (handles auth logic), UI views (presents to users), and error handling.
The Authentication View Model
This coordinates between iOS's passkey system and your server.
import SwiftUI import AuthenticationServices @MainActor class PasskeyAuthViewModel: ObservableObject { @Published var isAuthenticated = false @Published var errorMessage: String? @Published var isLoading = false private let relyingPartyIdentifier = "myapp.example.com" func registerPasskey(username: String) async { isLoading = true errorMessage = nil do { // Get registration challenge from server let challenge = try await fetchRegistrationChallenge(username: username) let userID = try await createUserID(username: username) // Create the passkey let provider = ASAuthorizationPlatformPublicKeyCredentialProvider( relyingPartyIdentifier: relyingPartyIdentifier ) let registrationRequest = provider.createCredentialRegistrationRequest( challenge: challenge, name: username, userID: userID ) // This triggers Face ID/Touch ID let authController = ASAuthorizationController( authorizationRequests: [registrationRequest] ) let delegate = PasskeyDelegate() authController.delegate = delegate authController.performRequests() // Wait for user to complete biometric authentication if let credential = await delegate.waitForCredential() { try await sendCredentialToServer(credential: credential, username: username) isAuthenticated = true } } catch let error as ASAuthorizationError { handleAuthError(error) } catch { errorMessage = "Registration failed: \(error.localizedDescription)" } isLoading = false } func signInWithPasskey() async { isLoading = true errorMessage = nil do { let challenge = try await fetchAuthenticationChallenge() let provider = ASAuthorizationPlatformPublicKeyCredentialProvider( relyingPartyIdentifier: relyingPartyIdentifier ) let assertionRequest = provider.createCredentialAssertionRequest( challenge: challenge ) let authController = ASAuthorizationController( authorizationRequests: [assertionRequest] ) let delegate = PasskeyDelegate() authController.delegate = delegate authController.performRequests() if let assertion = await delegate.waitForAssertion() { try await verifyAssertionWithServer(assertion: assertion) isAuthenticated = true } } catch let error as ASAuthorizationError { handleAuthError(error) } catch { errorMessage = "Sign in failed: \(error.localizedDescription)" } isLoading = false } private func handleAuthError(_ error: ASAuthorizationError) { switch error.code { case .canceled: errorMessage = nil // User canceled, don't show error case .failed: errorMessage = "Authentication failed. Please try again." case .notHandled: errorMessage = "Your device doesn't support passkeys. Please use password sign-in." case .unknown: errorMessage = "Something went wrong. Please try again." @unknown default: errorMessage = "Authentication error occurred." } } } The view model manages state (authentication, errors, loading), handles registration and sign-in flows, and presents biometric prompts. Error handling distinguishes between user cancellation (no message) and actual failures.
The Passkey Delegate
Bridges callback-based API to async/await:
class PasskeyDelegate: NSObject, ASAuthorizationControllerDelegate { private var continuation: CheckedContinuation<ASAuthorizationPlatformPublicKeyCredentialRegistration?, Never>? private var assertionContinuation: CheckedContinuation<ASAuthorizationPlatformPublicKeyCredentialAssertion?, Never>? func waitForCredential() async -> ASAuthorizationPlatformPublicKeyCredentialRegistration? { await withCheckedContinuation { continuation in self.continuation = continuation } } func waitForAssertion() async -> ASAuthorizationPlatformPublicKeyCredentialAssertion? { await withCheckedContinuation { continuation in self.assertionContinuation = continuation } } func authorizationController(controller: ASAuthorizationController, didCompleteWithAuthorization authorization: ASAuthorization) { if let credential = authorization.credential as? ASAuthorizationPlatformPublicKeyCredentialRegistration { continuation?.resume(returning: credential) continuation = nil } else if let assertion = authorization.credential as? ASAuthorizationPlatformPublicKeyCredentialAssertion { assertionContinuation?.resume(returning: assertion) assertionContinuation = nil } } func authorizationController(controller: ASAuthorizationController, didCompleteWithError error: Error) { continuation?.resume(returning: nil) assertionContinuation?.resume(returning: nil) continuation = nil assertionContinuation = nil } } Server Communication
Implement these based on your backend API:
extension PasskeyAuthViewModel { private func fetchRegistrationChallenge(username: String) async throws -> Data { // POST /auth/passkey/register/challenge // Body: { "username": "user@example.com" } // Response: { "challenge": "base64-encoded-challenge" } let url = URL(string: "https://myapp.example.com/auth/passkey/register/challenge")! var request = URLRequest(url: url) request.httpMethod = "POST" request.setValue("application/json", forHTTPHeaderField: "Content-Type") let body = ["username": username] request.httpBody = try JSONEncoder().encode(body) let (data, _) = try await URLSession.shared.data(for: request) let response = try JSONDecoder().decode(ChallengeResponse.self, from: data) return Data(base64Encoded: response.challenge)! } private func createUserID(username: String) async throws -> Data { // Your server should return a stable user identifier // This is typically returned with the challenge or created when registering // For new users, generate a UUID on the server // This is a simplified version - coordinate with your backend return username.data(using: .utf8)! } private func sendCredentialToServer( credential: ASAuthorizationPlatformPublicKeyCredentialRegistration, username: String ) async throws { // POST /auth/passkey/register // Send the public key credential to your server for storage let url = URL(string: "https://myapp.example.com/auth/passkey/register")! var request = URLRequest(url: url) request.httpMethod = "POST" request.setValue("application/json", forHTTPHeaderField: "Content-Type") let body: [String: Any] = [ "username": username, "credentialId": credential.credentialID.base64EncodedString(), "attestationObject": credential.rawAttestationObject?.base64EncodedString() ?? "", "clientDataJSON": credential.rawClientDataJSON.base64EncodedString() ] request.httpBody = try JSONSerialization.data(withJSONObject: body) let (_, response) = try await URLSession.shared.data(for: request) guard let httpResponse = response as? HTTPURLResponse, httpResponse.statusCode == 200 else { throw URLError(.badServerResponse) } } private func fetchAuthenticationChallenge() async throws -> Data { // POST /auth/passkey/authenticate/challenge // Response: { "challenge": "base64-encoded-challenge" } let url = URL(string: "https://myapp.example.com/auth/passkey/authenticate/challenge")! var request = URLRequest(url: url) request.httpMethod = "POST" let (data, _) = try await URLSession.shared.data(for: request) let response = try JSONDecoder().decode(ChallengeResponse.self, from: data) return Data(base64Encoded: response.challenge)! } private func verifyAssertionWithServer( assertion: ASAuthorizationPlatformPublicKeyCredentialAssertion ) async throws { // POST /auth/passkey/authenticate/verify // Send the signature for server verification let url = URL(string: "https://myapp.example.com/auth/passkey/authenticate/verify")! var request = URLRequest(url: url) request.httpMethod = "POST" request.setValue("application/json", forHTTPHeaderField: "Content-Type") let body: [String: Any] = [ "credentialId": assertion.credentialID.base64EncodedString(), "authenticatorData": assertion.rawAuthenticatorData.base64EncodedString(), "signature": assertion.signature.base64EncodedString(), "userHandle": assertion.userID.base64EncodedString(), "clientDataJSON": assertion.rawClientDataJSON.base64EncodedString() ] request.httpBody = try JSONSerialization.data(withJSONObject: body) let (_, response) = try await URLSession.shared.data(for: request) guard let httpResponse = response as? HTTPURLResponse, httpResponse.statusCode == 200 else { throw URLError(.badServerResponse) } } } struct ChallengeResponse: Codable { let challenge: String } The Sign-In View
Main interface users see:
struct PasskeyAuthView: View { @StateObject private var viewModel = PasskeyAuthViewModel() @State private var showingRegistration = false var body: some View { NavigationStack { VStack(spacing: 24) { if viewModel.isAuthenticated { authenticatedView } else { signInView } } .padding() .navigationTitle("Welcome") } .sheet(isPresented: $showingRegistration) { RegistrationView(viewModel: viewModel) } } private var signInView: some View { VStack(spacing: 20) { Spacer() Image(systemName: "person.badge.key.fill") .font(.system(size: 64)) .foregroundStyle(.blue) Text("Sign in with Passkey") .font(.title2) .fontWeight(.semibold) Text("Use Face ID or Touch ID to sign in securely") .font(.subheadline) .foregroundStyle(.secondary) .multilineTextAlignment(.center) if let error = viewModel.errorMessage { Text(error) .font(.caption) .foregroundStyle(.red) .padding() .background(Color.red.opacity(0.1)) .cornerRadius(8) } Spacer() Button { Task { await viewModel.signInWithPasskey() } } label: { HStack { if viewModel.isLoading { ProgressView() .tint(.white) } else { Image(systemName: "faceid") Text("Sign In") } } .frame(maxWidth: .infinity) .padding() .background(Color.blue) .foregroundStyle(.white) .cornerRadius(12) } .disabled(viewModel.isLoading) Button("Create New Account") { showingRegistration = true } .padding(.top, 8) } } private var authenticatedView: some View { VStack(spacing: 20) { Image(systemName: "checkmark.circle.fill") .font(.system(size: 64)) .foregroundStyle(.green) Text("You're Signed In") .font(.title2) .fontWeight(.semibold) Text("Authentication successful") .font(.subheadline) .foregroundStyle(.secondary) } } } The Registration View
Collects username and creates passkey:
struct RegistrationView: View { @ObservedObject var viewModel: PasskeyAuthViewModel @State private var username = "" @Environment(\.dismiss) var dismiss var body: some View { NavigationStack { Form { Section { TextField("Email", text: $username) .textContentType(.username) .textInputAutocapitalization(.never) .keyboardType(.emailAddress) } header: { Text("Create Your Account") } footer: { Text("Your passkey will be saved to iCloud Keychain and work across all your Apple devices") } Section { Button { Task { await viewModel.registerPasskey(username: username) if viewModel.isAuthenticated { dismiss() } } } label: { HStack { if viewModel.isLoading { ProgressView() } Text("Create Passkey Account") } } .disabled(username.isEmpty || viewModel.isLoading) } if let error = viewModel.errorMessage { Section { Text(error) .foregroundStyle(.red) .font(.caption) } } } .navigationTitle("Sign Up") .navigationBarTitleDisplayMode(.inline) .toolbar { ToolbarItem(placement: .cancellationAction) { Button("Cancel") { dismiss() } } } } } } AutoFill Support
Enable passkey suggestions in text fields:
TextField("Email or Username", text: $username) .textContentType(.username) .textInputAutocapitalization(.never) For immediate suggestions, set preferImmediatelyAvailableCredentials to true in your assertion request.
Edge Cases to Handle
Device support: Some devices lack Face ID/Touch ID
iCloud Keychain: Some users disable it
Multiple passkeys: Users can have multiple per domain
Account recovery: Need alternative auth if users lose all devices
Testing Checklist
- Test on physical devices (simulator doesn't support Secure Enclave)
- Create account and sign in (happy path)
- Cancel Face ID prompt (should handle gracefully)
- Test sync across multiple devices
- Test on device without your passkey
Done
You have working passkey authentication in SwiftUI. The core flow handles registration, sign-in, and errors. Adapt the server integration to your backend and add user management features.
Top comments (2)
Device support: Some devices lack Face ID/Touch ID
iCloud Keychain: Some users disable it
Multiple passkeys: Users can have multiple per domain
Account recovery: Need alternative auth if users lose all devices
👏