How to Build Passkey Authentication in SwiftUI
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
-
ASAuthorizationPlatformPublicKeyCredentialProvider — Constructed with your relying-party domain (
"soarias.com"). This must exactly match thewebcredentialsentry in your Apple App Site Association file hosted athttps://soarias.com/.well-known/apple-app-site-association, or all requests will silently fail. -
Registration vs. assertion requests —
createCredentialRegistrationRequestgenerates a new public/private key pair on the Secure Enclave and attests it;createCredentialAssertionRequestuses an existing passkey to sign a server challenge. Both require a fresh, single-usechallenge: Datafrom your backend to prevent replay attacks. -
Checked continuation bridge —
performRequest(_:)wraps the callback-basedASAuthorizationControllerDelegateinwithCheckedThrowingContinuationso callers canawaitresults cleanly without retaining the controller or creating race conditions. The continuation is stored inself.continuationand resolved in the delegate callbacks. -
Presentation context —
presentationAnchor(for:)resolves the activeUIWindowfromUIApplication.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. -
Server-side verification — The delegate success path hands you
rawAttestationObject(registration) orrawAuthenticatorData + 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
-
iOS 16 note: Passkeys (platform public key credentials) were introduced at WWDC 22 and are supported from iOS 16, but the
performRequests(options:)auto-fill API and several delegation improvements require iOS 17. Guard with#available(iOS 17, *)and provide a fallback Sign in with Apple or password flow for iOS 16 devices still in your install base. -
Relying-party mismatch: The single most common failure is a mismatch between the domain in
ASAuthorizationPlatformPublicKeyCredentialProvider(relyingPartyIdentifier:)and thewebcredentialskey in your AASA file. Apple fetches the AASA file at app install; if it does not list your app's team ID + bundle ID the controller will returnASAuthorizationError.Code.failedwith no useful message. Test with a real device — the Simulator does not enforce AASA validation. - Never store or cache the challenge: Each challenge must be cryptographically random, single-use, and server-generated. Reusing challenges or generating them client-side makes your authentication trivially bypassable. Also set a short TTL (30–60 s) on the server so that captured challenges cannot be replayed. Failing to do this negates the entire security model of WebAuthn.
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.