Skip to main content

iOS WebAuthn / Face ID Integration Guide

Complete guide for integrating ZewstID's WebAuthn/Passkeys with Face ID and Touch ID in your iOS app.

Prerequisites

  • iOS 16.0+ (for best WebAuthn support)
  • Xcode 14+
  • Swift 5.7+
  • HTTPS backend (WebAuthn requires secure context)
  • Associated Domains configured

Project Setup

1. Configure Info.plist

Add Face ID usage description:

<key>NSFaceIDUsageDescription</key> <string>We use Face ID to securely authenticate you to your account</string>

2. Configure Associated Domains

In Xcode:

  1. Select your target → Signing & Capabilities
  2. Add "Associated Domains" capability
  3. Add domain:
    webcredentials:api.zewstid.com

In your app's entitlements file:

<key>com.apple.developer.associated-domains</key> <array> <string>webcredentials:api.zewstid.com</string> </array>

3. Add URL Scheme (for OAuth)

<key>CFBundleURLTypes</key> <array> <dict> <key>CFBundleURLSchemes</key> <array> <string>yourapp</string> </array> </dict> </array>

Implementation

ZewstBiometricManager.swift

Complete production-ready implementation:

import Foundation import AuthenticationServices import CryptoKit /// Manages WebAuthn/Passkey authentication with ZewstID @MainActor class ZewstBiometricManager: NSObject { // MARK: - Configuration private let apiBaseURL: String private let clientID: String private let rpID: String /// Initialize biometric manager /// - Parameters: /// - apiBaseURL: ZewstID API base URL (e.g., "https://api.zewstid.com") /// - clientID: Your OAuth client ID /// - rpID: Relying Party ID (default: "zewst.com") init(apiBaseURL: String, clientID: String, rpID: String = "zewst.com") { self.apiBaseURL = apiBaseURL self.clientID = clientID self.rpID = rpID super.init() } // MARK: - Registration /// Register Face ID/Touch ID for the current user /// Call this after user has authenticated with password /// - Parameter idToken: Valid ID token from password login (use ID token, not access token) /// - Returns: Credential information /// - Throws: BiometricError func registerBiometric(idToken: String, credentialName: String = "iPhone") async throws -> BiometricCredential { // Step 1: Get registration options from server let options = try await getRegistrationOptions(idToken: idToken) // Step 2: Create credential with platform authenticator let credential = try await createPlatformCredential(options: options) // Step 3: Send credential to server for verification let verifiedCredential = try await verifyRegistration( credential: credential, challenge: options.challenge, credentialName: credentialName, idToken: idToken ) // Step 4: Store credential info locally (optional) storeCredentialInfo(verifiedCredential) return verifiedCredential } private func getRegistrationOptions(idToken: String) async throws -> RegistrationOptions { var request = URLRequest(url: URL(string: "\(apiBaseURL)/api/v1/auth/webauthn/register/begin")!) request.httpMethod = "POST" request.setValue("Bearer \(idToken)", forHTTPHeaderField: "Authorization") request.setValue("application/json", forHTTPHeaderField: "Content-Type") let (data, response) = try await URLSession.shared.data(for: request) guard let httpResponse = response as? HTTPURLResponse, httpResponse.statusCode == 200 else { throw BiometricError.registrationFailed("Failed to get registration options") } let decoder = JSONDecoder() let result = try decoder.decode(RegistrationOptionsResponse.self, from: data) return result.options } private func createPlatformCredential(options: RegistrationOptions) async throws -> ASAuthorizationPlatformPublicKeyCredentialRegistration { let challenge = Data(base64Encoded: options.challenge)! let userID = Data(base64Encoded: options.user.id)! let publicKeyCredentialProvider = ASAuthorizationPlatformPublicKeyCredentialProvider( relyingPartyIdentifier: rpID ) let registrationRequest = publicKeyCredentialProvider.createCredentialRegistrationRequest( challenge: challenge, name: options.user.name, userID: userID ) // Request credential from system let authController = ASAuthorizationController(authorizationRequests: [registrationRequest]) let delegate = AuthDelegate() authController.delegate = delegate authController.presentationContextProvider = self return try await withCheckedThrowingContinuation { continuation in delegate.onSuccess = { authorization in if let credential = authorization.credential as? ASAuthorizationPlatformPublicKeyCredentialRegistration { continuation.resume(returning: credential) } else { continuation.resume(throwing: BiometricError.invalidCredential) } } delegate.onError = { error in continuation.resume(throwing: error) } authController.performRequests() } } private func verifyRegistration( credential: ASAuthorizationPlatformPublicKeyCredentialRegistration, challenge: String, credentialName: String, idToken: String ) async throws -> BiometricCredential { // Prepare registration response let registrationResponse = [ "id": credential.credentialID.base64EncodedString(), "rawId": credential.credentialID.base64EncodedString(), "type": "public-key", "response": [ "clientDataJSON": credential.rawClientDataJSON.base64EncodedString(), "attestationObject": credential.rawAttestationObject!.base64EncodedString() ] ] as [String : Any] let requestBody: [String: Any] = [ "response": registrationResponse, "challenge": challenge, "credentialName": credentialName ] var request = URLRequest(url: URL(string: "\(apiBaseURL)/api/v1/auth/webauthn/register/finish")!) request.httpMethod = "POST" request.setValue("Bearer \(idToken)", forHTTPHeaderField: "Authorization") request.setValue("application/json", forHTTPHeaderField: "Content-Type") request.httpBody = try JSONSerialization.data(withJSONObject: requestBody) let (data, response) = try await URLSession.shared.data(for: request) guard let httpResponse = response as? HTTPURLResponse, httpResponse.statusCode == 200 else { throw BiometricError.verificationFailed("Failed to verify registration") } let decoder = JSONDecoder() let result = try decoder.decode(RegistrationVerificationResponse.self, from: data) return result.credential } // MARK: - Authentication /// Authenticate user with Face ID/Touch ID /// - Parameter email: Optional email to filter credentials /// - Returns: Authentication tokens /// - Throws: BiometricError func authenticateWithBiometric(email: String? = nil) async throws -> AuthTokens { // Step 1: Get authentication options let options = try await getAuthenticationOptions(email: email) // Step 2: Get assertion from platform authenticator let assertion = try await getPlatformAssertion(options: options) // Step 3: Verify assertion and get tokens let tokens = try await verifyAuthentication( assertion: assertion, challenge: options.challenge ) return tokens } private func getAuthenticationOptions(email: String?) async throws -> AuthenticationOptions { var request = URLRequest(url: URL(string: "\(apiBaseURL)/api/v1/auth/webauthn/authenticate/begin")!) request.httpMethod = "POST" request.setValue("application/json", forHTTPHeaderField: "Content-Type") if let email = email { let requestBody = ["email": email] request.httpBody = try JSONSerialization.data(withJSONObject: requestBody) } let (data, response) = try await URLSession.shared.data(for: request) guard let httpResponse = response as? HTTPURLResponse, httpResponse.statusCode == 200 else { throw BiometricError.authenticationFailed("Failed to get authentication options") } let decoder = JSONDecoder() let result = try decoder.decode(AuthenticationOptionsResponse.self, from: data) return result.options } private func getPlatformAssertion(options: AuthenticationOptions) async throws -> ASAuthorizationPlatformPublicKeyCredentialAssertion { let challenge = Data(base64Encoded: options.challenge)! let publicKeyCredentialProvider = ASAuthorizationPlatformPublicKeyCredentialProvider( relyingPartyIdentifier: rpID ) let assertionRequest = publicKeyCredentialProvider.createCredentialAssertionRequest( challenge: challenge ) // Request assertion from system let authController = ASAuthorizationController(authorizationRequests: [assertionRequest]) let delegate = AuthDelegate() authController.delegate = delegate authController.presentationContextProvider = self return try await withCheckedThrowingContinuation { continuation in delegate.onSuccess = { authorization in if let assertion = authorization.credential as? ASAuthorizationPlatformPublicKeyCredentialAssertion { continuation.resume(returning: assertion) } else { continuation.resume(throwing: BiometricError.invalidCredential) } } delegate.onError = { error in continuation.resume(throwing: error) } authController.performRequests() } } private func verifyAuthentication( assertion: ASAuthorizationPlatformPublicKeyCredentialAssertion, challenge: String ) async throws -> AuthTokens { // Prepare authentication response let authenticationResponse = [ "id": assertion.credentialID.base64EncodedString(), "rawId": assertion.credentialID.base64EncodedString(), "type": "public-key", "response": [ "clientDataJSON": assertion.rawClientDataJSON.base64EncodedString(), "authenticatorData": assertion.rawAuthenticatorData.base64EncodedString(), "signature": assertion.signature.base64EncodedString(), "userHandle": assertion.userID.base64EncodedString() ] ] as [String : Any] let requestBody: [String: Any] = [ "response": authenticationResponse, "challenge": challenge, "clientId": clientID ] var request = URLRequest(url: URL(string: "\(apiBaseURL)/api/v1/auth/webauthn/authenticate/finish")!) request.httpMethod = "POST" request.setValue("application/json", forHTTPHeaderField: "Content-Type") request.httpBody = try JSONSerialization.data(withJSONObject: requestBody) let (data, response) = try await URLSession.shared.data(for: request) guard let httpResponse = response as? HTTPURLResponse, httpResponse.statusCode == 200 else { throw BiometricError.authenticationFailed("Failed to verify authentication") } let decoder = JSONDecoder() let tokens = try decoder.decode(AuthTokens.self, from: data) // Store tokens securely try storeTokens(tokens) return tokens } // MARK: - Credential Management /// Check if biometric authentication is available func isBiometricAvailable() -> Bool { let provider = ASAuthorizationPlatformPublicKeyCredentialProvider(relyingPartyIdentifier: rpID) // Check if platform authenticator is available return true // iOS 16+ always has platform authenticator support } /// Check if user has registered credentials func hasRegisteredCredentials() -> Bool { return UserDefaults.standard.bool(forKey: "has_webauthn_credential") } private func storeCredentialInfo(_ credential: BiometricCredential) { UserDefaults.standard.set(true, forKey: "has_webauthn_credential") UserDefaults.standard.set(credential.id, forKey: "webauthn_credential_id") UserDefaults.standard.set(credential.name, forKey: "webauthn_credential_name") } // MARK: - Token Management private func storeTokens(_ tokens: AuthTokens) throws { // Store in Keychain for security let keychainQuery: [String: Any] = [ kSecClass as String: kSecClassGenericPassword, kSecAttrAccount as String: "access_token", kSecValueData as String: tokens.access_token.data(using: .utf8)!, kSecAttrAccessible as String: kSecAttrAccessibleAfterFirstUnlock ] // Delete old token if exists SecItemDelete(keychainQuery as CFDictionary) // Store new token let status = SecItemAdd(keychainQuery as CFDictionary, nil) guard status == errSecSuccess else { throw BiometricError.storageFailed("Failed to store tokens") } } } // MARK: - ASAuthorizationControllerPresentationContextProviding extension ZewstBiometricManager: ASAuthorizationControllerPresentationContextProviding { func presentationAnchor(for controller: ASAuthorizationController) -> ASPresentationAnchor { return UIApplication.shared.windows.first { $0.isKeyWindow }! } } // MARK: - Authorization Delegate private class AuthDelegate: NSObject, ASAuthorizationControllerDelegate { var onSuccess: ((ASAuthorization) -> Void)? var onError: ((Error) -> Void)? func authorizationController(controller: ASAuthorizationController, didCompleteWithAuthorization authorization: ASAuthorization) { onSuccess?(authorization) } func authorizationController(controller: ASAuthorizationController, didCompleteWithError error: Error) { onError?(error) } } // MARK: - Models struct RegistrationOptionsResponse: Codable { let options: RegistrationOptions } struct RegistrationOptions: Codable { let challenge: String let user: UserInfo let rp: RelyingPartyInfo let pubKeyCredParams: [PublicKeyCredentialParameters] let timeout: Int let attestation: String struct UserInfo: Codable { let id: String let name: String let displayName: String } struct RelyingPartyInfo: Codable { let name: String let id: String } struct PublicKeyCredentialParameters: Codable { let type: String let alg: Int } } struct RegistrationVerificationResponse: Codable { let success: Bool let credential: BiometricCredential let message: String } struct BiometricCredential: Codable { let id: String let name: String let createdAt: String let deviceType: String } struct AuthenticationOptionsResponse: Codable { let options: AuthenticationOptions } struct AuthenticationOptions: Codable { let challenge: String let timeout: Int let rpId: String let userVerification: String } struct AuthTokens: Codable { let access_token: String let refresh_token: String let expires_in: Int let token_type: String } enum BiometricError: LocalizedError { case registrationFailed(String) case verificationFailed(String) case authenticationFailed(String) case invalidCredential case storageFailed(String) case notAvailable var errorDescription: String? { switch self { case .registrationFailed(let msg): return "Registration failed: \(msg)" case .verificationFailed(let msg): return "Verification failed: \(msg)" case .authenticationFailed(let msg): return "Authentication failed: \(msg)" case .invalidCredential: return "Invalid credential received" case .storageFailed(let msg): return "Storage failed: \(msg)" case .notAvailable: return "Biometric authentication not available" } } }

Usage Examples

Example 1: Password + Face ID Registration

import SwiftUI struct LoginView: View { @State private var email = "" @State private var password = "" @State private var showingBiometricSetup = false @State private var idToken: String? let biometricManager = ZewstBiometricManager( apiBaseURL: "https://api.zewstid.com", clientID: "your-client-id" ) var body: some View { VStack(spacing: 20) { TextField("Email", text: $email) .textContentType(.emailAddress) .keyboardType(.emailAddress) SecureField("Password", text: $password) .textContentType(.password) Button("Sign In") { Task { await signInWithPassword() } } .buttonStyle(.borderedProminent) } .sheet(isPresented: $showingBiometricSetup) { BiometricSetupView( biometricManager: biometricManager, idToken: idToken! ) } } func signInWithPassword() async { // Step 1: Authenticate with password via OAuth guard let tokens = await authenticateWithPassword(email: email, password: password) else { return } idToken = tokens.idToken // Step 2: Prompt to set up Face ID if !biometricManager.hasRegisteredCredentials() { showingBiometricSetup = true } } func authenticateWithPassword(email: String, password: String) async -> (idToken: String, accessToken: String, refreshToken: String)? { // Your OAuth password flow implementation // Returns access token if successful return nil // Placeholder } } struct BiometricSetupView: View { let biometricManager: ZewstBiometricManager let idToken: String @Environment(\.dismiss) var dismiss @State private var isRegistering = false @State private var errorMessage: String? var body: some View { VStack(spacing: 30) { Image(systemName: "faceid") .font(.system(size: 80)) .foregroundColor(.blue) Text("Set Up Face ID") .font(.title) .bold() Text("Sign in faster and more securely with Face ID") .multilineTextAlignment(.center) .foregroundColor(.secondary) if let error = errorMessage { Text(error) .foregroundColor(.red) .font(.caption) } Button(action: registerFaceID) { if isRegistering { ProgressView() } else { Text("Enable Face ID") } } .buttonStyle(.borderedProminent) .disabled(isRegistering) Button("Skip for Now") { dismiss() } .foregroundColor(.secondary) } .padding() } func registerFaceID() { isRegistering = true errorMessage = nil Task { do { let credential = try await biometricManager.registerBiometric( idToken: idToken, credentialName: UIDevice.current.name ) print("✅ Face ID registered: \(credential.name)") dismiss() } catch { errorMessage = error.localizedDescription isRegistering = false } } } }

Example 2: Passwordless Face ID Login

struct BiometricLoginView: View { let biometricManager = ZewstBiometricManager( apiBaseURL: "https://api.zewstid.com", clientID: "your-client-id" ) @State private var isAuthenticating = false @State private var errorMessage: String? var body: some View { VStack(spacing: 40) { Image(systemName: "faceid") .font(.system(size: 100)) .foregroundColor(.blue) Text("Sign in with Face ID") .font(.title2) .bold() if let error = errorMessage { Text(error) .foregroundColor(.red) .font(.caption) } Button(action: signInWithFaceID) { HStack { if isAuthenticating { ProgressView() .tint(.white) } else { Image(systemName: "faceid") Text("Sign In with Face ID") } } } .buttonStyle(.borderedProminent) .disabled(isAuthenticating) Button("Use Password Instead") { // Navigate to password login } .foregroundColor(.secondary) } .padding() } func signInWithFaceID() { isAuthenticating = true errorMessage = nil Task { do { let tokens = try await biometricManager.authenticateWithBiometric() print("✅ Authenticated with Face ID") print("Access Token: \(tokens.access_token)") // Navigate to main app } catch { errorMessage = error.localizedDescription isAuthenticating = false } } } }

Example 3: Conditional MFA

class AuthenticationService { let biometricManager = ZewstBiometricManager( apiBaseURL: "https://api.zewstid.com", clientID: "your-client-id" ) func signIn(email: String, password: String) async throws -> AuthTokens { // Step 1: Authenticate with password let passwordTokens = try await authenticateWithPassword(email: email, password: password) // Step 2: Check if MFA is required if shouldRequireMFA(for: passwordTokens) { print("⚠️ High-risk login detected - requiring Face ID") // Step 3: Require Face ID verification let mfaTokens = try await biometricManager.authenticateWithBiometric(email: email) return mfaTokens } return passwordTokens } func shouldRequireMFA(for tokens: AuthTokens) -> Bool { // Implement your risk assessment logic // Examples: // - New device // - Unusual location // - Suspicious activity // - Sensitive operation requested return false // Placeholder } }

Testing

1. Simulator Testing

WebAuthn is not available in iOS Simulator. Test on physical device with:

  • Face ID enabled (iPhone X or newer)
  • Touch ID enabled (iPhone SE, iPad)

2. Test User

let testEmail = "test@example.com" let testPassword = "TestPassword123!"

3. Debug Logging

Add logging to track authentication flow:

func registerBiometric(idToken: String, credentialName: String = "iPhone") async throws -> BiometricCredential { print("🔐 [Registration] Starting...") print("📡 [Registration] Fetching options from server...") let options = try await getRegistrationOptions(idToken: idToken) print("✅ [Registration] Options received: \(options.challenge)") print("📱 [Registration] Creating credential with platform authenticator...") let credential = try await createPlatformCredential(options: options) print("✅ [Registration] Credential created") print("📡 [Registration] Verifying with server...") let verifiedCredential = try await verifyRegistration( credential: credential, challenge: options.challenge, credentialName: credentialName, idToken: idToken ) print("✅ [Registration] Registration complete!") return verifiedCredential }

Error Handling

Common Errors

ErrorCauseSolution
LAError.biometryNotEnrolled
Face ID not set upPrompt user to enroll Face ID in Settings
LAError.userCancel
User cancelled Face IDShow message: "Authentication cancelled"
ASAuthorizationError.canceled
User cancelled promptAllow retry
HTTP 400Invalid challengeRe-request challenge from server
HTTP 401Expired tokenRefresh access token
HTTP 404Credential not foundUser needs to re-register

Example Error Handling

do { let tokens = try await biometricManager.authenticateWithBiometric() // Success } catch BiometricError.authenticationFailed(let msg) { showAlert("Authentication failed: \(msg)") } catch BiometricError.notAvailable { showAlert("Face ID is not available. Please use password.") } catch { showAlert("An error occurred: \(error.localizedDescription)") }

Security Best Practices

  1. Store tokens in Keychain, not UserDefaults
  2. Validate server responses - Check status codes and parse errors
  3. Handle network failures gracefully - Show user-friendly messages
  4. Don't cache challenges - Request new challenge each time
  5. Log security events - Track registration and authentication attempts
  6. Provide fallback - Always allow password login as backup
  7. Test on real devices - Simulator doesn't support WebAuthn
  8. Handle rotation - Store multiple credentials for different devices

Production Checklist

  • Info.plist configured with Face ID usage description
  • Associated Domains configured correctly
  • HTTPS endpoints only
  • Error handling implemented
  • Keychain storage for tokens
  • Fallback to password implemented
  • User feedback for biometric prompts
  • Testing on physical devices
  • Analytics/logging added
  • App Store compliance verified

Next Steps

Was this page helpful?

Let us know how we can improve our documentation