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