DEV Community

ArshTechPro
ArshTechPro

Posted on

Building Passkey Authentication in SwiftUI: Part 2

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."
        }
    }
}
Enter fullscreen mode Exit fullscreen mode

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
    }
}
Enter fullscreen mode Exit fullscreen mode

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
}
Enter fullscreen mode Exit fullscreen mode

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)
        }
    }
}
Enter fullscreen mode Exit fullscreen mode

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()
                    }
                }
            }
        }
    }
}
Enter fullscreen mode Exit fullscreen mode

AutoFill Support

Enable passkey suggestions in text fields:

TextField("Email or Username", text: $username)
    .textContentType(.username)
    .textInputAutocapitalization(.never)
Enter fullscreen mode Exit fullscreen mode

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 (1)

Collapse
 
arshtechpro profile image
ArshTechPro

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