developers

How to Sign Up and Log In with Passkeys on iOS Using Auth0's Native Login

Learn how to implement passkey signup, login, and logout in an iOS app using Auth0's Native Login and the Auth0.swift SDK.

Auth0 supports Native Login with passkeys for iOS apps — currently in Limited Early Access. This means your iOS app can create and use passkeys without redirecting users to a browser. The entire authentication flow happens natively, using Apple's AuthenticationServices framework and the Auth0.swift SDK.

In this tutorial, you'll build an iOS app that lets users sign up, log in, and log out with passkeys. By the end, you'll have a working app that creates passkeys on signup, authenticates with them on login, and stores Auth0 tokens securely in the Keychain.

If you're looking for the Android equivalent, check out How to Sign Up and Log In with Passkeys in Android Using Auth0's Native Login.

Passkeys on iOS

iOS supports passkeys through Apple's AuthenticationServices framework — specifically the ASAuthorizationPlatformPublicKeyCredentialProvider class. This is Apple's implementation of the WebAuthn standard, and it's what enables passkeys to work across your Apple devices via iCloud Keychain.

When you use passkeys with Auth0, your app acts as the client, Auth0 acts as the relying party, and Apple's passkey provider (iCloud Keychain) acts as the authenticator.

Sign up

The passkey signup flow has three steps:

  1. Your app calls Auth0's /passkey/register endpoint. Auth0 returns a PasskeySignupChallenge containing the WebAuthn registration options — the relying party ID, a cryptographic challenge, and user information.
  2. Your app passes the challenge to ASAuthorizationPlatformPublicKeyCredentialProvider, which prompts the user to create a passkey via Face ID or Touch ID. Apple creates a new public-private key pair and returns a registration credential.
  3. Your app sends the registration credential back to Auth0, which verifies it and returns OAuth tokens (access token, ID token, and refresh token).

Login

The passkey login flow follows the same three-step pattern:

  1. Your app calls Auth0's /passkey/challenge endpoint. Auth0 returns a PasskeyLoginChallenge with the relying party ID and a cryptographic challenge.
  2. Your app passes the challenge to ASAuthorizationPlatformPublicKeyCredentialProvider, which prompts the user to authenticate with an existing passkey. Apple signs the challenge with the stored private key and returns an assertion credential.
  3. Your app sends the assertion credential back to Auth0, which verifies the signature and returns OAuth tokens.

The key difference: signup uses createCredentialRegistrationRequest (creates a new key pair), while login uses createCredentialAssertionRequest (uses an existing one). Both flows converge on the same Auth0 /oauth/token endpoint for the token exchange.

Get the Starter App

Clone the sample app to follow along:

git clone https://github.com/auth0-blog/auth0-ios-native-login-passkeys

The starter project is a SwiftUI app with two screens: a login screen where users enter their email and choose to sign up or log in, and a home screen that displays their name and ID token after authentication.

LoginView vs HomeView

Prerequisites

  • An Auth0 account. Native Login with passkeys is in Limited Early Access — contact Auth0 support to enable it for your tenant.
  • An Auth0 Native Application.
  • Xcode 16 or later.
  • A physical iOS device running iOS 16.6+, or a simulator with iCloud Keychain configured. Passkeys are backed by iCloud Keychain, so they work most reliably on real devices.
  • An Apple Developer account (required for the Associated Domains capability).

Configure Auth0 with Your iOS App

Before writing any code, you need to configure four things in the Auth0 Dashboard and one thing in Xcode.

Enable passkeys in Auth0

Enable passkeys on your Auth0 database connection. Go to Authentication > Database > your connection and toggle passkeys on.

For a detailed walkthrough, see Configure Passkey Policy for Database Connections.

Configure passkey Auth0 Dashboard

Enable passkey grant

Your Auth0 Native Application needs the passkey grant type enabled:

  1. Go to Applications > Applications and select your native app.
  2. Scroll down to Advanced Settings > Grant Types.
  3. Check Passkey.
  4. Click Save Changes.
Advanced Settings Grant Types Auth0 Dashboard

Configure iOS device settings

Auth0 needs your app's identity to serve the correct Apple App Site Association (AASA) file. This is what lets iOS verify that your app is associated with your Auth0 custom domain:

  1. In your application settings, go to Advanced Settings > Device Settings.
  2. Under the iOS section, add your Apple Team ID and your app's Bundle Identifier.
  3. Click Save.
Configure iOS device settings

Configure a custom domain

Passkeys are bound to a relying party domain. Auth0's default *.auth0.com domains won't work — you need a custom domain.

If you don't have one set up yet, go to Branding > Custom Domains and follow the steps. Auth0 offers managed certificates so you don't need to bring your own — and custom domains are included in the free plan.

Learn how to set up a custom domain

Configure Associated Domains in Xcode

The final configuration step happens in your Xcode project. Associated Domains tell iOS that your app is linked to your Auth0 custom domain for passkey operations:

  1. Open your project in Xcode.
  2. Select your target, then go to Signing & Capabilities.
  3. Click + Capability and add Associated Domains.
  4. Add: webcredentials:YOUR_AUTH0_CUSTOM_DOMAIN

For example, if your custom domain is login.example.com, add webcredentials:login.example.com.

You can verify the AASA file is being served correctly by visiting:

https://YOUR_AUTH0_CUSTOM_DOMAIN/.well-known/apple-app-site-association  

Sign Up with a Passkey in Auth0

Now let's build the app. Start by adding the Auth0.swift SDK to your project.

Add Dependencies

In Xcode, go to File > Add Package Dependencies and add:

https://github.com/auth0/Auth0.swift  

Set the version to 2.12.0 or later — this is the first version with passkey support. Add both the Auth0 and JWTDecode libraries to your target.

Configure Auth0.plist

Create an Auth0.plist file in your project with your Auth0 credentials:

<?xml *version*="1.0" *encoding*="UTF-8"?>  
<!DOCTYPE *plist* PUBLIC "-//Apple//DTD PLIST 1.0//EN"  
 "http://www.apple.com/DTDs/PropertyList-1.0.dtd">  
<plist *version*="1.0">  
<dict>  
   <key>ClientId</key>  
   <string>YOUR_AUTH0_CLIENT_ID</string>  
   <key>Domain</key>  
   <string>YOUR_AUTH0_CUSTOM_DOMAIN</string>  
</dict>  
</plist>  

Important: Use your custom domain here, not the default .auth0.com domain. Passkeys won't work without it.

Build the LoginView

The login screen has an email field (needed for signup) and two buttons — one for login, one for signup:

import SwiftUI

struct LoginView: View {  
   @ObservedObject var viewModel: AuthViewModel  
   @State private var email = ""

   var body: some View {  
       VStack(spacing: 24) {  
           Spacer()

           Image(systemName: "person.badge.key.fill")  
               .font(.system(size: 80))  
               .foregroundStyle(.blue)

           Text("Welcome to Native Loginnwith Passkeys!")  
               .font(.title2)  
               .fontWeight(.semibold)  
               .multilineTextAlignment(.center)

           TextField("Email", text: $email)  
               .textFieldStyle(.roundedBorder)  
               .textContentType(.emailAddress)  
               .keyboardType(.emailAddress)  
               .autocorrectionDisabled()  
               .textInputAutocapitalization(.never)  
               .padding(.horizontal)

           if viewModel.isLoading {  
               ProgressView()  
           }

           if let error = viewModel.error {  
               Text(error)  
                   .foregroundStyle(.red)  
                   .font(.caption)  
                   .padding(.horizontal)  
           }

           Button("Login") {  
               Task { await viewModel.loginWithPasskey() }  
           }  
           .buttonStyle(.borderedProminent)  
           .controlSize(.large)  
           .disabled(viewModel.isLoading)

           Text("or")  
               .foregroundStyle(.secondary)

           Button("Sign Up") {  
               Task { await viewModel.signUpWithPasskey(email: email) }  
           }  
           .buttonStyle(.bordered)  
           .controlSize(.large)  
           .disabled(email.isEmpty || viewModel.isLoading)

           Spacer()  
       }  
       .padding()  
   }  
}  

The Login button doesn't need an email — passkeys are discoverable credentials, so iOS will show all available passkeys for the relying party. The Sign Up button requires an email because Auth0 needs it to create the user account.

Create the AuthViewModel

All the passkey logic lives in a single AuthViewModel class. It handles the Auth0 API calls, the Apple passkey prompts, and credential storage:

import AuthenticationServices  
import Auth0  
import JWTDecode  
import combine  
@MainActor  
class AuthViewModel: NSObject, ObservableObject {

   @Published var isAuthenticated = false  
   @Published var isLoading = false  
   @Published var error: String?  
   @Published var userName = ""  
   @Published var idToken = ""

   private let authentication = Auth0.authentication()  
   private let credentialsManager: CredentialsManager  
   private let connection = "Username-Password-Authentication"

   // 👇 Used to bridge ASAuthorizationController's delegate callbacks into async/await  
   private var authorizationContinuation: CheckedContinuation<ASAuthorizationCredential, Error>?

   override init() {  
       self.credentialsManager = CredentialsManager(authentication: Auth0.authentication())  
       super.init()  
       self.isAuthenticated = credentialsManager.hasValid()  
   }

   // ... signup and login methods go here  
}  

The AuthViewModel inherits from NSObject because it needs to conform to ASAuthorizationControllerDelegate — Apple's delegate protocol for handling passkey responses. The authorizationContinuation property is the key to making the delegate pattern work with async/await, which we'll get to shortly.

On initialization, the view model checks credentialsManager.hasValid() to skip the login screen if the user already has valid stored credentials.

Implement signUpWithPasskey

Here's the complete signup method:

func signUpWithPasskey(email: String) async {  
   isLoading = true  
   error = nil

   do {  
       // 1. Request a signup challenge from Auth0  
       let challenge = try await authentication  
           .passkeySignupChallenge(email: email, connection: connection)  
           .start()

       // 2. Create a new passkey using Apple's ASAuthorization API  
       let provider = ASAuthorizationPlatformPublicKeyCredentialProvider(  
           relyingPartyIdentifier: challenge.relyingPartyId  
       )  
       let registrationRequest = provider.createCredentialRegistrationRequest(  
           challenge: challenge.challengeData,  
           name: challenge.userName,  
           userID: challenge.userId  
       )  
       let credential = try await performAuthorizationRequest(registrationRequest)

       guard let registration = credential  
               as? ASAuthorizationPlatformPublicKeyCredentialRegistration else {  
           throw PasskeyError.unexpectedCredentialType  
       }

       // 3. Complete signup by exchanging the passkey for Auth0 tokens  
       let credentials = try await authentication  
           .login(passkey: registration, challenge: challenge, connection: connection)  
           .start()

       _ = credentialsManager.store(credentials: credentials)  
       isAuthenticated = true  
   } catch ASAuthorizationError.canceled {  
       // User cancelled the passkey prompt — no error to show  
   } catch {  
       self.error = error.localizedDescription  
   }

   isLoading = false  
}  

In step 1, passkeySignupChallenge calls Auth0's POST /passkey/register endpoint. Auth0 returns a PasskeySignupChallenge containing the relyingPartyId, challengeData, userId, and userName — everything Apple needs to create the passkey.

In step 2, ASAuthorizationPlatformPublicKeyCredentialProvider takes the relying party ID and creates a registration request with the challenge, user name, and user ID from Auth0. The performAuthorizationRequest helper (shown below) presents the Face ID / Touch ID prompt and returns the credential.

In step 3, the Auth0.swift SDK makes this clean: ASAuthorizationPlatformPublicKeyCredentialRegistration conforms to the SDK's SignupPasskey protocol automatically, so you pass it directly to authentication.login(passkey:challenge:connection:). No JSON serialization, no manual mapping — the SDK handles the WebAuthn response encoding under the hood. This calls POST /oauth/token with the urn:okta:params:oauth:grant-type:webauthn grant type and returns standard OAuth credentials.

Using Apple's ASAuthorization API

The one piece of plumbing you need is a way to bridge ASAuthorizationController's delegate pattern into async/await. The performAuthorizationRequest method does this using withCheckedThrowingContinuation:

private var authorizationContinuation: CheckedContinuation<ASAuthorizationCredential, *Error*>?

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

When the user completes (or cancels) the Face ID / Touch ID prompt, ASAuthorizationController calls one of the delegate methods. These resume the continuation, which unblocks the await:

extension AuthViewModel: ASAuthorizationControllerDelegate {

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

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

You also need to provide a presentation anchor — the window where the passkey sheet appears:

extension AuthViewModel: ASAuthorizationControllerPresentationContextProviding {

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

This helper and the delegate extensions are reused for both signup and login — you write them once and both flows use them.

Login with a Passkey in Auth0

The login flow mirrors signup, but instead of creating a new passkey, it retrieves an existing one:

func loginWithPasskey() async {  
   isLoading = true  
   error = nil

   do {  
       // 1. Request a login challenge from Auth0  
       let challenge = try await authentication  
           .passkeyLoginChallenge(connection: connection)  
           .start()

       // 2. Retrieve an existing passkey using Apple's ASAuthorization API  
       let provider = ASAuthorizationPlatformPublicKeyCredentialProvider(  
           relyingPartyIdentifier: challenge.relyingPartyId  
       )  
       let assertionRequest = provider.createCredentialAssertionRequest(  
           challenge: challenge.challengeData  
       )  
       let credential = try await performAuthorizationRequest(assertionRequest)

       guard let assertion = credential  
               as? ASAuthorizationPlatformPublicKeyCredentialAssertion else {  
           throw PasskeyError.unexpectedCredentialType  
       }

       // 3. Complete login by exchanging the passkey for Auth0 tokens  
       let credentials = try await authentication  
           .login(passkey: assertion, challenge: challenge, connection: connection)  
           .start()

       _ = credentialsManager.store(credentials: credentials)  
       isAuthenticated = true  
   } catch ASAuthorizationError.canceled {  
       // User cancelled the passkey prompt — no error to show  
   } catch {  
       self.error = error.localizedDescription  
   }

   isLoading = false  
}  

Spot the differences from signup:

  • Step 1 calls passkeyLoginChallenge instead of passkeySignupChallenge. The login challenge (PasskeyLoginChallenge) is simpler — it only has relyingPartyId and challengeData. No user information, because the authenticator already knows which credentials exist for this relying party.
  • Step 2 calls createCredentialAssertionRequest instead of createCredentialRegistrationRequest. The assertion request only needs the challenge — no user name or user ID. iOS shows the user a list of available passkeys for the domain.
  • Step 3 casts to ASAuthorizationPlatformPublicKeyCredentialAssertion (which conforms to LoginPasskey) instead of the registration type. The SDK has two overloads of login(passkey:challenge:connection:) — one for LoginPasskey, one for SignupPasskey — so the code reads the same either way.

Both flows use the same performAuthorizationRequest helper and the same delegate callbacks. Both end with storing credentials and navigating to the home screen.

Store Auth0 credentials

After both signup and login, the app stores the returned credentials using Auth0's CredentialsManager:

private let credentialsManager: CredentialsManager

// In init():  
self.credentialsManager = CredentialsManager(authentication: Auth0.authentication())

// After successful authentication:  
_ = credentialsManager.store(credentials: credentials)  

CredentialsManager persists tokens in the iOS Keychain via SimpleKeychain. It also handles token refresh automatically when you call credentials() — if the access token has expired but a refresh token is available, it renews the tokens transparently.

Build the HomeView

The home screen shows the authenticated user's name, their raw ID token, and a logout button:

import SwiftUI

struct HomeView: View {  
   @ObservedObject var viewModel: AuthViewModel

   var body: some View {  
       VStack(spacing: 20) {  
           Text("Welcome, (viewModel.userName)!")  
               .font(.title)  
               .fontWeight(.bold)  
               .padding(.top)

           ScrollView {  
               Text(viewModel.idToken)  
                   .font(.system(.caption, design: .monospaced))  
                   .padding()  
                   .frame(maxWidth: .infinity, alignment: .leading)  
                   .background(Color(.systemGray6))  
                   .clipShape(RoundedRectangle(cornerRadius: 8))  
           }

           Button("Logout", role: .destructive) {  
               viewModel.logout()  
           }  
           .buttonStyle(.borderedProminent)  
           .controlSize(.large)  
           .padding(.bottom)  
       }  
       .padding()  
       .task {  
           await viewModel.loadUserProfile()  
       }  
   }  
}  

Load the user profile

When HomeView appears, it calls loadUserProfile() to retrieve the stored credentials and decode the user's name from the ID token:

func loadUserProfile() async {  
   do {  
       let credentials = try await credentialsManager.credentials()  
       idToken = credentials.idToken  
       let jwt = try decode(jwt: credentials.idToken)  
       userName = jwt["name"].string ?? jwt["email"].string ?? "User"  
   } catch {  
       self.error = error.localizedDescription  
       isAuthenticated = false  
   }  
}  

credentialsManager.credentials() returns the stored tokens. If the access token has expired, it automatically refreshes using the refresh token before returning. The decode(jwt:) function comes from JWTDecode.swift, which is included as a dependency of Auth0.swift.

Wire up navigation

The app entry point switches between LoginView and HomeView based on the authentication state:

import SwiftUI

@main  
struct NativePasskeysApp: App {  
   @StateObject private var viewModel = AuthViewModel()

   var body: some Scene {  
       WindowGroup {  
           if viewModel.isAuthenticated {  
               HomeView(viewModel: viewModel)  
           } else {  
               LoginView(viewModel: viewModel)  
           }  
       }  
   }  
}  

Because isAuthenticated is @Published, SwiftUI automatically handles the transition when it changes.

Logout

Logging out clears the stored credentials and resets the view model:

func logout() {  
   _ = credentialsManager.clear()  
   isAuthenticated = false  
   userName = ""  
   idToken = ""  
}  

credentialsManager.clear() removes the tokens from the Keychain. Setting isAuthenticated = false triggers SwiftUI to navigate back to the login screen.

Note: This clears the local session only. It doesn't revoke the tokens on the Auth0 side. For a production app, you may want to call credentialsManager.revoke() instead, which revokes the refresh token with Auth0 before clearing local storage.

Summary

You've built an iOS app that uses Auth0's Native Login to:

  • Sign up with a passkey — Auth0 generates the WebAuthn challenge, Apple creates the passkey via Face ID / Touch ID, and Auth0 verifies the credential and returns tokens.
  • Log in with a passkey — same three-step flow, but retrieving an existing passkey instead of creating a new one.
  • Log out — clear stored credentials from the Keychain.

The Auth0.swift SDK handles the API communication and WebAuthn encoding, while Apple's ASAuthorizationPlatformPublicKeyCredentialProvider handles the on-device passkey ceremony. The protocol conformances between the two (ASAuthorizationPlatformPublicKeyCredentialRegistration conforms to SignupPasskey, ASAuthorizationPlatformPublicKeyCredentialAssertion conforms to LoginPasskey) mean there's no manual JSON serialization involved.

Native Login with passkeys is currently in Limited Early Access. For the latest status and documentation, see Native Passkeys for Mobile Applications.

You can find the complete code for this tutorial on GitHub.