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 …


This content originally appeared on DEV Community and was authored by ArshTechPro

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.


This content originally appeared on DEV Community and was authored by ArshTechPro


Print Share Comment Cite Upload Translate Updates
APA

ArshTechPro | Sciencx (2025-10-30T09:45:57+00:00) Building Passkey Authentication in SwiftUI: Part 2. Retrieved from https://www.scien.cx/2025/10/30/building-passkey-authentication-in-swiftui-part-2/

MLA
" » Building Passkey Authentication in SwiftUI: Part 2." ArshTechPro | Sciencx - Thursday October 30, 2025, https://www.scien.cx/2025/10/30/building-passkey-authentication-in-swiftui-part-2/
HARVARD
ArshTechPro | Sciencx Thursday October 30, 2025 » Building Passkey Authentication in SwiftUI: Part 2., viewed ,<https://www.scien.cx/2025/10/30/building-passkey-authentication-in-swiftui-part-2/>
VANCOUVER
ArshTechPro | Sciencx - » Building Passkey Authentication in SwiftUI: Part 2. [Internet]. [Accessed ]. Available from: https://www.scien.cx/2025/10/30/building-passkey-authentication-in-swiftui-part-2/
CHICAGO
" » Building Passkey Authentication in SwiftUI: Part 2." ArshTechPro | Sciencx - Accessed . https://www.scien.cx/2025/10/30/building-passkey-authentication-in-swiftui-part-2/
IEEE
" » Building Passkey Authentication in SwiftUI: Part 2." ArshTechPro | Sciencx [Online]. Available: https://www.scien.cx/2025/10/30/building-passkey-authentication-in-swiftui-part-2/. [Accessed: ]
rf:citation
» Building Passkey Authentication in SwiftUI: Part 2 | ArshTechPro | Sciencx | https://www.scien.cx/2025/10/30/building-passkey-authentication-in-swiftui-part-2/ |

Please log in to upload a file.




There are no updates yet.
Click the Upload button above to add an update.

You must be logged in to translate posts. Please log in or register.