```html SwiftUI: How to Build Passkey Auth (iOS 17+, 2026)

How to Build Passkey Authentication in SwiftUI

iOS 17+ Xcode 16+ Advanced APIs: AuthenticationServices Updated: May 11, 2026
TL;DR

Create an ASAuthorizationPlatformPublicKeyCredentialProvider with your relying-party domain, build a registration or assertion request, then present it using ASAuthorizationController inside a SwiftUI-friendly coordinator. The controller drives Touch ID / Face ID, iCloud Keychain sync, and cross-device passkey flows automatically.

import AuthenticationServices

let provider = ASAuthorizationPlatformPublicKeyCredentialProvider(
    relyingPartyIdentifier: "soarias.com"
)

// Registration
let regRequest = provider.createCredentialRegistrationRequest(
    challenge: serverChallenge,
    name: "Alice",
    userID: userID
)

// Sign-in
let assertRequest = provider.createCredentialAssertionRequest(
    challenge: serverChallenge
)

let controller = ASAuthorizationController(
    authorizationRequests: [regRequest]   // or assertRequest
)
controller.delegate = delegate
controller.presentationContextProvider = context
controller.performRequests()

Full implementation

The cleanest SwiftUI pattern is an @Observable PasskeyManager class that owns the ASAuthorizationController lifecycle and exposes simple async methods. A thin AuthView binds to that manager and handles registration, sign-in, and error display. Because ASAuthorizationController requires a UIWindow presentation context, we bridge via ASAuthorizationControllerPresentationContextProviding using the active UIWindowScene.

import SwiftUI
import AuthenticationServices

// MARK: - Manager

@Observable
final class PasskeyManager: NSObject {

    enum AuthState { case idle, registering, authenticating, success(String), failure(String) }
    var state: AuthState = .idle

    private let relyingParty = "soarias.com"
    private var continuation: CheckedContinuation<ASAuthorization, Error>?

    // MARK: Register

    func register(username: String, userID: Data, challenge: Data) async {
        state = .registering
        do {
            let auth = try await performRequest(
                makeRegistrationRequest(username: username,
                                        userID: userID,
                                        challenge: challenge)
            )
            if let cred = auth.credential
                as? ASAuthorizationPlatformPublicKeyCredentialRegistration {
                // Send cred.rawAttestationObject + cred.rawClientDataJSON to your server
                state = .success("Passkey registered for \(cred.displayName ?? "user")")
            }
        } catch {
            state = .failure(error.localizedDescription)
        }
    }

    // MARK: Sign In

    func signIn(challenge: Data) async {
        state = .authenticating
        do {
            let auth = try await performRequest(
                makeAssertionRequest(challenge: challenge)
            )
            if let cred = auth.credential
                as? ASAuthorizationPlatformPublicKeyCredentialAssertion {
                // Verify cred.signature + cred.rawAuthenticatorData on your server
                state = .success("Signed in as \(cred.userID.base64EncodedString())")
            }
        } catch {
            state = .failure(error.localizedDescription)
        }
    }

    // MARK: Private helpers

    private func makeRegistrationRequest(username: String,
                                         userID: Data,
                                         challenge: Data)
        -> ASAuthorizationRequest
    {
        let provider = ASAuthorizationPlatformPublicKeyCredentialProvider(
            relyingPartyIdentifier: relyingParty
        )
        let req = provider.createCredentialRegistrationRequest(
            challenge: challenge,
            name: username,
            userID: userID
        )
        req.displayName = username
        return req
    }

    private func makeAssertionRequest(challenge: Data) -> ASAuthorizationRequest {
        let provider = ASAuthorizationPlatformPublicKeyCredentialProvider(
            relyingPartyIdentifier: relyingParty
        )
        return provider.createCredentialAssertionRequest(challenge: challenge)
    }

    @MainActor
    private func performRequest(_ request: ASAuthorizationRequest) async throws -> ASAuthorization {
        try await withCheckedThrowingContinuation { continuation in
            self.continuation = continuation
            let controller = ASAuthorizationController(authorizationRequests: [request])
            controller.delegate = self
            controller.presentationContextProvider = self
            controller.performRequests()
        }
    }
}

// MARK: - ASAuthorizationControllerDelegate

extension PasskeyManager: ASAuthorizationControllerDelegate {

    func authorizationController(controller: ASAuthorizationController,
                                 didCompleteWithAuthorization authorization: ASAuthorization) {
        continuation?.resume(returning: authorization)
        continuation = nil
    }

    func authorizationController(controller: ASAuthorizationController,
                                 didCompleteWithError error: Error) {
        continuation?.resume(throwing: error)
        continuation = nil
    }
}

// MARK: - ASAuthorizationControllerPresentationContextProviding

extension PasskeyManager: ASAuthorizationControllerPresentationContextProviding {

    func presentationAnchor(for controller: ASAuthorizationController) -> ASPresentationAnchor {
        UIApplication.shared.connectedScenes
            .compactMap { $0 as? UIWindowScene }
            .flatMap { $0.windows }
            .first { $0.isKeyWindow }
            ?? UIWindow()
    }
}

// MARK: - View

struct AuthView: View {

    @State private var manager = PasskeyManager()
    @State private var username = ""

    // In production these bytes come from your server
    private let demoChallenge = Data("demo-challenge-bytes-from-server".utf8)
    private let demoUserID    = Data("user-unique-id-12345".utf8)

    var body: some View {
        NavigationStack {
            VStack(spacing: 24) {
                TextField("Username", text: $username)
                    .textFieldStyle(.roundedBorder)
                    .textContentType(.username)
                    .autocorrectionDisabled()
                    .accessibilityLabel("Username")

                Button("Register Passkey") {
                    Task {
                        await manager.register(username: username,
                                               userID: demoUserID,
                                               challenge: demoChallenge)
                    }
                }
                .buttonStyle(.borderedProminent)
                .disabled(username.isEmpty)
                .accessibilityLabel("Register with passkey")

                Button("Sign In with Passkey") {
                    Task { await manager.signIn(challenge: demoChallenge) }
                }
                .buttonStyle(.bordered)
                .accessibilityLabel("Sign in with passkey")

                statusView
            }
            .padding()
            .navigationTitle("Passkey Auth")
        }
    }

    @ViewBuilder
    private var statusView: some View {
        switch manager.state {
        case .idle:              EmptyView()
        case .registering:       ProgressView("Registering…")
        case .authenticating:    ProgressView("Authenticating…")
        case .success(let msg):  Label(msg, systemImage: "checkmark.seal.fill").foregroundStyle(.green)
        case .failure(let msg):  Label(msg, systemImage: "xmark.octagon.fill").foregroundStyle(.red)
        }
    }
}

// MARK: - Preview

#Preview {
    AuthView()
}

How it works

  1. ASAuthorizationPlatformPublicKeyCredentialProvider — Constructed with your relying-party domain ("soarias.com"). This must exactly match the webcredentials entry in your Apple App Site Association file hosted at https://soarias.com/.well-known/apple-app-site-association, or all requests will silently fail.
  2. Registration vs. assertion requestscreateCredentialRegistrationRequest generates a new public/private key pair on the Secure Enclave and attests it; createCredentialAssertionRequest uses an existing passkey to sign a server challenge. Both require a fresh, single-use challenge: Data from your backend to prevent replay attacks.
  3. Checked continuation bridgeperformRequest(_:) wraps the callback-based ASAuthorizationControllerDelegate in withCheckedThrowingContinuation so callers can await results cleanly without retaining the controller or creating race conditions. The continuation is stored in self.continuation and resolved in the delegate callbacks.
  4. Presentation contextpresentationAnchor(for:) resolves the active UIWindow from UIApplication.shared.connectedScenes. This is required; if you return a detached or nil window the sheet will not appear on iPad split-screen or Stage Manager.
  5. Server-side verification — The delegate success path hands you rawAttestationObject (registration) or rawAuthenticatorData + signature (assertion). These must be verified on your server using a WebAuthn library — the iOS SDK does not validate them for you. Only after server verification should you issue a session token.

Variants

Conditional UI (auto-fill passkey prompt in a text field)

iOS 17 supports passkey auto-fill — the QuickType bar shows a passkey suggestion when the text field is focused, with no extra button required. Call performRequests(options:) with .preferImmediatelyAvailableCredentials and set .textContentType(.username) on the field.

// Call this when the username field appears on screen
@MainActor
func beginAutoFill(challenge: Data) {
    let provider = ASAuthorizationPlatformPublicKeyCredentialProvider(
        relyingPartyIdentifier: relyingParty
    )
    let assertRequest = provider.createCredentialAssertionRequest(challenge: challenge)

    // Also include password autofill so the sheet shows both options
    let passwordRequest = ASAuthorizationPasswordProvider().createRequest()

    let controller = ASAuthorizationController(
        authorizationRequests: [assertRequest, passwordRequest]
    )
    controller.delegate = self
    controller.presentationContextProvider = self
    // .preferImmediatelyAvailableCredentials suppresses the modal
    // when no matching credential exists — fails silently instead
    controller.performRequests(
        options: .preferImmediatelyAvailableCredentials
    )
}

// In your SwiftUI view, trigger this on appear:
.onAppear { Task { manager.beginAutoFill(challenge: challenge) } }
// Text field must declare:
.textContentType(.username)

Cross-device passkey (FIDO hybrid / QR code)

To let users sign in on a Mac or Windows machine using a nearby iPhone, no code change is needed — ASAuthorizationController automatically offers the QR-code/Bluetooth hybrid flow when no local passkey is found. Ensure your relying party's apple-app-site-association also includes a webcredentials entry, and the flow works end-to-end. You can hint the user with a subtitle in your UI: "Or scan a QR code with your iPhone" to set expectations.

Common pitfalls

Prompt this with Claude Code

When using Soarias or Claude Code directly to implement this:

Implement passkey authentication in SwiftUI for iOS 17+.
Use AuthenticationServices (ASAuthorizationPlatformPublicKeyCredentialProvider,
ASAuthorizationController, ASAuthorizationControllerDelegate).
Include both registration and assertion flows.
Bridge delegate callbacks with withCheckedThrowingContinuation.
Make it accessible (VoiceOver labels on all buttons and status messages).
Add a #Preview with realistic sample data.

In the Soarias Build phase, paste this prompt into a feature task to scaffold the full PasskeyManager and AuthView in one shot, then use the Review phase to verify your AASA file and server-side challenge logic before shipping.

Related

FAQ

Does this work on iOS 16?

Passkeys (ASAuthorizationPlatformPublicKeyCredentialProvider) were introduced in iOS 16, so registration and sign-in work on iOS 16+. However, the performRequests(options: .preferImmediatelyAvailableCredentials) auto-fill API and several delegate improvements are iOS 17+. Guard those paths with #available(iOS 17, *) and fall back to the modal sheet on iOS 16.

Do I need a real server to test passkeys?

Yes, in practice. The AASA validation requires a live HTTPS domain that lists your app, which means the Simulator is unreliable for end-to-end testing — use a physical device paired to a staging server. For local development you can temporarily use localhost with a self-signed certificate via a proxy (e.g., ngrok), provided you add the ngrok domain to your Associated Domains entitlement during testing. Never ship with a non-production relying-party domain.

What is the UIKit equivalent?

In UIKit you use exactly the same AuthenticationServices classes — ASAuthorizationController, its delegate, and the presentation context provider — so the manager layer is identical. The only UIKit-specific difference is that you would typically trigger performRequests() from a UIViewController action rather than a SwiftUI Button task, and you would implement presentationAnchor(for:) returning self.view.window from the view controller directly.

Last reviewed: 2026-05-11 by the Soarias team.

```