Passkeys and AuthenticationServices: The Future of iOS Authentication


Passwords are the weakest link in app security. Users reuse them, phishing attacks steal them, and server breaches expose them. Passkeys — built on the FIDO2/WebAuthn standard — replace passwords with public-key cryptography. The private key never leaves the device, there’s nothing to phish, and nothing is stored on your server that can be breached. Apple has been shipping passkey support since iOS 16, and with each release the integration gets smoother. iOS 26 introduces ASAuthorizationAccountCreationProvider for one-sheet sign-up, automatic background passkey upgrades for existing password users, and ASCredentialUpdater for credential managers to push updates to the system.

This post covers the full passkey lifecycle on iOS: registration, authentication, credential management, and the iOS 26 APIs that eliminate friction from the sign-up flow. It assumes familiarity with Keychain storage and networking fundamentals. Server-side WebAuthn implementation details are out of scope — we focus exclusively on the client-side AuthenticationServices APIs.

Note: Passkeys require iOS 16+ for basic support. The ASAuthorizationAccountCreationProvider and automatic upgrade APIs require iOS 26+. All code in this post uses Swift 6 strict concurrency.

Contents

The Problem

Consider the sign-in flow for a Pixar movie fan community app — “Pixar Vault” — where users save watchlists, post reviews, and discuss theories about the Pixar universe. The traditional approach looks like this:

// The password-based approach — vulnerable by design
@Observable @MainActor
final class PixarVaultAuthManager {
    private(set) var isAuthenticated = false

    func signIn(email: String, password: String) async throws {
        let response = try await APIClient.post("/auth/login", body: [
            "email": email,
            "password": password
        ])

        try KeychainHelper.save(response.token, for: "session_token")
        isAuthenticated = true
    }

    func signUp(email: String, password: String) async throws {
        // Hope the user picks a strong password
        // Hope they don't reuse it from another service
        // Hope your server never gets breached
        let response = try await APIClient.post("/auth/register", body: [
            "email": email,
            "password": password
        ])

        try KeychainHelper.save(response.token, for: "session_token")
        isAuthenticated = true
    }
}

Every step in this flow is a potential attack surface. The password travels over the network (encrypted by TLS, but still). Your server stores a hash that could be cracked if breached. The user might enter their credentials into a phishing site that looks like yours. Password reset flows add another vector. And the UX is terrible — users either use weak passwords they can remember or strong passwords they forget.

Passkeys eliminate all of these vectors by replacing the shared secret (password) with asymmetric cryptography. The private key is generated on-device, synced through iCloud Keychain, and never sent to your server. Your server stores only the public key — which is useless to an attacker.

Passkey Fundamentals

Apple Docs: ASAuthorization — AuthenticationServices

A passkey is a FIDO2 credential consisting of a public-private key pair. Here’s the flow:

Registration: Your server sends a challenge (random bytes). The device generates a new key pair, signs the challenge with the private key, and sends the public key + signed challenge back. The server stores the public key and the credential ID.

Authentication: Your server sends a new challenge. The device signs it with the stored private key (after biometric verification). The server verifies the signature using the stored public key.

The private key is stored in the Secure Enclave (on device) and synced across the user’s Apple devices through iCloud Keychain. It never leaves the Apple ecosystem. There is no shared secret — nothing to phish, nothing to breach.

Associated Domains

Before writing any code, you need to establish the link between your app and your server. Add the webcredentials associated domain to your app’s entitlements:

webcredentials:pixarvault.example.com

And host an apple-app-site-association file at https://pixarvault.example.com/.well-known/apple-app-site-association:

{
  "webcredentials": {
    "apps": ["TEAMID.com.example.pixarvault"]
  }
}

This bidirectional trust ensures that only your app can create and use passkeys for your domain. Without this, the system will reject passkey registration.

Registering a Passkey

Apple Docs: ASAuthorizationPlatformPublicKeyCredentialProvider — AuthenticationServices

Registration creates a new passkey and associates it with a user account on your server. The flow requires a server-generated challenge:

import AuthenticationServices

@Observable @MainActor
final class PasskeyManager: NSObject {
    private let domain = "pixarvault.example.com"
    private(set) var isAuthenticated = false
    private var authController: ASAuthorizationController?

    private var registrationContinuation:
        CheckedContinuation<
            ASAuthorizationPlatformPublicKeyCredentialRegistration,
            Error
        >?
    private var assertionContinuation:
        CheckedContinuation<
            ASAuthorizationPlatformPublicKeyCredentialAssertion,
            Error
        >?

    func registerPasskey(
        userID: Data,
        userName: String,
        challenge: Data
    ) async throws
        -> ASAuthorizationPlatformPublicKeyCredentialRegistration
    {
        let provider =
            ASAuthorizationPlatformPublicKeyCredentialProvider(
                relyingPartyIdentifier: domain
            )

        let request = provider.createCredentialRegistrationRequest(
            challenge: challenge,
            name: userName,
            userID: userID
        )

        return try await performRegistration(request: request)
    }

    private func performRegistration(
        request: ASAuthorizationRequest
    ) async throws
        -> ASAuthorizationPlatformPublicKeyCredentialRegistration
    {
        try await withCheckedThrowingContinuation { continuation in
            self.registrationContinuation = continuation

            let controller = ASAuthorizationController(
                authorizationRequests: [request]
            )
            controller.delegate = self
            controller.presentationContextProvider = self
            controller.performRequests()
            self.authController = controller
        }
    }
}

The ASAuthorizationController presents the system passkey sheet — the user sees their name and the relying party domain, authenticates with Face ID or Touch ID, and the credential is created. The delegate receives the result:

extension PasskeyManager: ASAuthorizationControllerDelegate {
    nonisolated func authorizationController(
        controller: ASAuthorizationController,
        didCompleteWithAuthorization authorization: ASAuthorization
    ) {
        Task { @MainActor in
            if let registration = authorization.credential
                as? ASAuthorizationPlatformPublicKeyCredentialRegistration
            {
                registrationContinuation?.resume(returning: registration)
                registrationContinuation = nil
            } else if let assertion = authorization.credential
                as? ASAuthorizationPlatformPublicKeyCredentialAssertion
            {
                assertionContinuation?.resume(returning: assertion)
                assertionContinuation = nil
            }
        }
    }

    nonisolated func authorizationController(
        controller: ASAuthorizationController,
        didCompleteWithError error: Error
    ) {
        Task { @MainActor in
            registrationContinuation?.resume(throwing: error)
            registrationContinuation = nil
            assertionContinuation?.resume(throwing: error)
            assertionContinuation = nil
        }
    }
}

extension PasskeyManager:
    ASAuthorizationControllerPresentationContextProviding
{
    nonisolated func presentationAnchor(
        for controller: ASAuthorizationController
    ) -> ASPresentationAnchor {
        // In a real app, return the key window
        ASPresentationAnchor()
    }
}

After registration succeeds, send the credential data to your server:

extension PasskeyManager {
    func completeRegistration(
        _ registration:
            ASAuthorizationPlatformPublicKeyCredentialRegistration
    ) async throws {
        let credentialID = registration.credentialID
        let attestationObject = registration.rawAttestationObject
        let clientDataJSON = registration.rawClientDataJSON

        try await APIClient.post("/auth/passkey/register", body: [
            "credentialID": credentialID.base64EncodedString(),
            "attestationObject":
                attestationObject?.base64EncodedString() ?? "",
            "clientDataJSON": clientDataJSON.base64EncodedString()
        ])

        isAuthenticated = true
    }
}

Authenticating with a Passkey

Apple Docs: ASAuthorizationPlatformPublicKeyCredentialProvider — AuthenticationServices

Authentication is simpler than registration. Your server provides a challenge, the device signs it with the stored private key, and the server verifies the signature:

extension PasskeyManager {
    func signInWithPasskey(challenge: Data) async throws {
        let provider =
            ASAuthorizationPlatformPublicKeyCredentialProvider(
                relyingPartyIdentifier: domain
            )

        let request = provider.createCredentialAssertionRequest(
            challenge: challenge
        )

        let assertion = try await performAssertion(request: request)

        try await APIClient.post("/auth/passkey/authenticate", body: [
            "credentialID":
                assertion.credentialID.base64EncodedString(),
            "authenticatorData":
                assertion.rawAuthenticatorData.base64EncodedString(),
            "clientDataJSON":
                assertion.rawClientDataJSON.base64EncodedString(),
            "signature":
                assertion.signature.base64EncodedString(),
            "userID":
                assertion.userID.base64EncodedString()
        ])

        isAuthenticated = true
    }

    private func performAssertion(
        request: ASAuthorizationRequest
    ) async throws
        -> ASAuthorizationPlatformPublicKeyCredentialAssertion
    {
        try await withCheckedThrowingContinuation { continuation in
            self.assertionContinuation = continuation

            let controller = ASAuthorizationController(
                authorizationRequests: [request]
            )
            controller.delegate = self
            controller.presentationContextProvider = self
            controller.performRequests()
            self.authController = controller
        }
    }
}

The system handles credential selection when a user has multiple passkeys. If only one passkey matches the relying party identifier, the system goes straight to biometric verification. If multiple exist, the user picks one from a sheet.

Conditional Mediation (AutoFill-Integrated Passkeys)

For the smoothest sign-in experience, use conditional mediation. This surfaces available passkeys in the keyboard’s AutoFill bar when the user taps a username or email field — no explicit “Sign in with Passkey” button required:

extension PasskeyManager {
    func beginAutoFillAssistedSignIn(challenge: Data) {
        let provider =
            ASAuthorizationPlatformPublicKeyCredentialProvider(
                relyingPartyIdentifier: domain
            )

        let request = provider.createCredentialAssertionRequest(
            challenge: challenge
        )

        let controller = ASAuthorizationController(
            authorizationRequests: [request]
        )
        controller.delegate = self
        controller.presentationContextProvider = self
        // Key difference — passkeys appear in AutoFill
        controller.performAutoFillAssistedRequests()
        self.authController = controller
    }
}

Tip: Call performAutoFillAssistedRequests() when the sign-in view appears, not when the user taps a button. The passkey suggestion appears alongside saved passwords in the AutoFill bar. If the user selects it, the delegate fires immediately with the assertion result.

One-Sheet Account Creation (iOS 26)

Apple Docs: ASAuthorizationAccountCreationProvider — AuthenticationServices

The biggest friction point in passkey adoption has been the sign-up flow. Traditional passkey registration requires your own UI for collecting user information (name, email), then a separate system sheet for passkey creation. iOS 26’s ASAuthorizationAccountCreationProvider collapses this into a single system-managed sheet that collects user information and creates the passkey in one step.

@available(iOS 26, *)
extension PasskeyManager {
    func createAccountWithPasskey() async throws {
        let provider = ASAuthorizationAccountCreationProvider()

        let request = provider.createRequest()
        request.relyingPartyIdentifier = domain
        request.requestedOperation = .create

        request.requestedCredentialTypes = [
            .platformPublicKey
        ]

        // Request user information fields
        request.userInformationRequest =
            ASAuthorizationAccountCreationProvider
                .UserInformationRequest()
        request.userInformationRequest?.requestedFields = [
            .name,
            .emailAddress
        ]

        let controller = ASAuthorizationController(
            authorizationRequests: [request]
        )
        controller.delegate = self
        controller.presentationContextProvider = self
        controller.performRequests()
        self.authController = controller
    }
}

The system presents a single sheet where the user enters their name and email (or these are auto-filled from their Apple ID), then creates the passkey with Face ID or Touch ID — all in one interaction. From the user’s perspective, account creation is as fast as Sign in with Apple, but with a standard passkey that works across platforms.

Handle the result in the delegate:

@available(iOS 26, *)
extension PasskeyManager {
    func handleAccountCreation(
        _ authorization: ASAuthorization
    ) async throws {
        guard let credential = authorization.credential
            as? ASAuthorizationPlatformPublicKeyCredentialRegistration
        else {
            throw AuthError.unexpectedCredentialType
        }

        let credentialID = credential.credentialID
        let attestationObject = credential.rawAttestationObject
        let clientDataJSON = credential.rawClientDataJSON

        try await APIClient.post("/auth/account/create", body: [
            "credentialID":
                credentialID.base64EncodedString(),
            "attestationObject":
                attestationObject?.base64EncodedString() ?? "",
            "clientDataJSON":
                clientDataJSON.base64EncodedString()
        ])

        isAuthenticated = true
    }
}

enum AuthError: LocalizedError {
    case unexpectedCredentialType

    var errorDescription: String? {
        switch self {
        case .unexpectedCredentialType:
            return "Received an unexpected credential type."
        }
    }
}

This is a significant UX improvement. The previous flow required navigating from your sign-up form to the passkey sheet and back. Users who abandoned the process mid-way had an incomplete account. The one-sheet approach is atomic — either the account and passkey are both created, or neither is.

Automatic Passkey Upgrades (iOS 26)

Most apps with existing user bases have millions of accounts using passwords. Converting them all to passkeys requires a gradual upgrade strategy. iOS 26 introduces automatic background passkey upgrades — the system can create a passkey for an existing password-based account without interrupting the user.

The mechanism works through the password AutoFill flow. When a user signs in with a saved password, the system can automatically offer to create a passkey for that account in the background:

@available(iOS 26, *)
extension PasskeyManager {
    /// The automatic upgrade is configured server-side in your
    /// apple-app-site-association file:
    ///
    /// {
    ///   "webcredentials": {
    ///     "apps": ["TEAMID.com.example.pixarvault"],
    ///     "passkey-upgrade": {
    ///       "enabled": true,
    ///       "endpoint":
    ///         "https://pixarvault.example.com/auth/passkey/upgrade"
    ///     }
    ///   }
    /// }
    ///

    /// Handle the upgrade when the system initiates it
    func handleAutomaticUpgrade(
        _ registration:
            ASAuthorizationPlatformPublicKeyCredentialRegistration,
        for existingUserID: String
    ) async throws {
        // The system created the passkey in the background.
        // Associate it with the existing account.
        try await APIClient.post("/auth/passkey/upgrade", body: [
            "userID": existingUserID,
            "credentialID":
                registration.credentialID.base64EncodedString(),
            "attestationObject":
                registration.rawAttestationObject?
                    .base64EncodedString() ?? "",
            "clientDataJSON":
                registration.rawClientDataJSON
                    .base64EncodedString()
        ])
    }
}

The beauty of automatic upgrades is that they’re invisible to the user. They sign in with their password as usual. The system silently creates a passkey in the background. Next time they sign in, the passkey is offered through AutoFill. Over time, your user base migrates to passkeys without any active effort on their part.

Warning: Automatic upgrades require careful server-side implementation. Your upgrade endpoint must verify that the request is legitimate (check the attestation) and that the user ID matches an existing account. A malicious request could attempt to associate a passkey with someone else’s account.

ASCredentialUpdater for Credential Managers

Apple Docs: ASCredentialIdentityStore — AuthenticationServices

If you’re building a credential manager (password manager) app, iOS 26 introduces ASCredentialUpdater — an API that lets your credential provider extension push updates to the system’s credential store without requiring the user to open your app:

@available(iOS 26, *)
final class PixarVaultCredentialUpdater {
    /// Push a new or updated passkey to the system credential store
    func updateCredential(
        credentialID: Data,
        relyingPartyIdentifier: String,
        userName: String,
        userHandle: Data
    ) async throws {
        let identity = ASPasskeyCredentialIdentity(
            relyingPartyIdentifier: relyingPartyIdentifier,
            userName: userName,
            credentialID: credentialID,
            userHandle: userHandle
        )

        try await ASCredentialIdentityStore.shared
            .saveCredentialIdentities([identity])
    }

    /// Remove credentials that have been deleted or revoked
    func removeCredential(
        credentialID: Data,
        relyingParty: String
    ) async throws {
        let identity = ASPasskeyCredentialIdentity(
            relyingPartyIdentifier: relyingParty,
            userName: "",
            credentialID: credentialID,
            userHandle: Data()
        )

        try await ASCredentialIdentityStore.shared
            .removeCredentialIdentities([identity])
    }
}

This is relevant primarily for apps that function as password/credential managers. If your app is a regular consumer app (like our Pixar Vault example), you don’t need ASCredentialUpdater — the system manages passkey storage for you through iCloud Keychain.

Tip: If you’re building a credential manager extension, use ASCredentialProviderViewController as your extension’s principal class. The system calls your extension when the user selects a credential from AutoFill. See WWDC 2024 Session 10125, “Streamline sign-in with passkey upgrades and credential managers,” for the full implementation walkthrough.

Performance Considerations

Passkey creation latency. The system sheet for passkey creation involves Secure Enclave key generation and biometric verification. Expect 1-3 seconds from user initiation to completion. This is inherent to the security model and cannot be optimized.

Challenge freshness. Challenges should be single-use and time-limited (typically 5 minutes). Generating them requires a server round-trip. Pre-fetch the challenge when the sign-in view appears, not when the user taps the button, to avoid stacking two sequential network calls.

struct SignInView: View {
    @State private var challenge: Data?
    let passkeyManager: PasskeyManager

    var body: some View {
        VStack {
            Text("Welcome to the Pixar Vault")
                .font(.title)

            Button("Sign In") {
                guard let challenge else { return }
                Task {
                    try await passkeyManager.signInWithPasskey(
                        challenge: challenge
                    )
                }
            }
            .disabled(challenge == nil)
        }
        .task {
            // Fetch challenge before the user taps the button
            challenge = try? await APIClient.fetchChallenge()
        }
    }
}

Cross-platform passkeys. Passkeys sync through iCloud Keychain across Apple devices. They also work cross-platform via QR code scanning (FIDO Cross-Device Authentication). The QR flow adds 5-10 seconds of overhead. Consider offering Sign in with Apple as an alternative for cross-platform scenarios where speed matters.

Fallback handling. Not all users have passkey-capable devices or iCloud Keychain enabled. Always provide a fallback authentication path (password, Sign in with Apple, magic link). Check for passkey support before showing passkey-specific UI:

extension PasskeyManager {
    var isPasskeySupported: Bool {
        if #available(iOS 16, *) {
            return true
        }
        return false
    }
}

When to Use (and When Not To)

ScenarioRecommendation
New consumer app with accountsOffer passkeys as the primary sign-up method with password fallback.
Existing app with password authAdd passkeys alongside passwords. Use automatic upgrades (iOS 26).
Enterprise/MDM-managed appsVerify your MDM profile allows iCloud Keychain sync.
Cross-platform authUse passkeys with QR code fallback. Works on Apple, Android, Windows.
Sign in with Apple is sufficientIf you don’t need your own account system, SIWA is simpler.
Credential manager appUse ASCredentialUpdater (iOS 26) and the credential provider extension.

Summary

  • Passkeys replace passwords with asymmetric cryptography. The private key stays on-device (Secure Enclave + iCloud Keychain). Your server stores only the public key.
  • ASAuthorizationPlatformPublicKeyCredentialProvider handles both registration (key pair creation) and authentication (challenge signing) through system-managed UI.
  • Conditional mediation (performAutoFillAssistedRequests) surfaces passkeys in the AutoFill bar for frictionless sign-in without a dedicated button.
  • iOS 26’s ASAuthorizationAccountCreationProvider collapses account creation and passkey registration into a single system sheet — dramatically reducing sign-up abandonment.
  • Automatic background passkey upgrades (iOS 26) silently migrate password users to passkeys during normal sign-in, requiring no user action.
  • ASCredentialUpdater enables credential manager apps to push updates to the system store without user interaction.

For a deeper look at securing the rest of your app, read App Security: Protecting Data and Preventing Common Vulnerabilities, which covers certificate pinning, jailbreak detection, and secure data storage patterns that complement passkey authentication.