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
ASAuthorizationAccountCreationProviderand automatic upgrade APIs require iOS 26+. All code in this post uses Swift 6 strict concurrency.
Contents
- The Problem
- Passkey Fundamentals
- Registering a Passkey
- Authenticating with a Passkey
- One-Sheet Account Creation (iOS 26)
- Automatic Passkey Upgrades (iOS 26)
- ASCredentialUpdater for Credential Managers
- Performance Considerations
- When to Use (and When Not To)
- Summary
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
ASCredentialProviderViewControlleras 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)
| Scenario | Recommendation |
|---|---|
| New consumer app with accounts | Offer passkeys as the primary sign-up method with password fallback. |
| Existing app with password auth | Add passkeys alongside passwords. Use automatic upgrades (iOS 26). |
| Enterprise/MDM-managed apps | Verify your MDM profile allows iCloud Keychain sync. |
| Cross-platform auth | Use passkeys with QR code fallback. Works on Apple, Android, Windows. |
| Sign in with Apple is sufficient | If you don’t need your own account system, SIWA is simpler. |
| Credential manager app | Use 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.
ASAuthorizationPlatformPublicKeyCredentialProviderhandles 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
ASAuthorizationAccountCreationProvidercollapses 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.
ASCredentialUpdaterenables 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.