Implement passkey authentication in web and mobile applications
This guide covers how to implement passkey authentication in your applications using Ory Kratos or Ory Network. Passkeys provide a passwordless authentication experience using WebAuthn across web browsers and mobile platforms.
This page assumes you have already configured the passkey method in your Ory configuration. See the Passkeys overview for initial setup instructions.
Code examples in this guide are illustrative and likely need adjustments based on your specific configuration, identity schema, and application requirements.
Overview
Passkey implementation differs between platforms:
- Web applications use browser-native WebAuthn APIs with JavaScript.
- Mobile applications use platform-specific credential management APIs (iOS AuthenticationServices, Android CredentialManager) with Ory's JSON API endpoints.
This guide focuses on the integration patterns for each platform.
Web implementation
For web applications, you can use the browser's native WebAuthn API to create and authenticate with passkeys.
Using Ory's webauthn.js
Ory provides a webauthn.js helper script that simplifies WebAuthn integration in browser flows. When you initialize a
registration or login flow through the browser, Ory automatically injects the necessary JavaScript to handle passkey operations.
<!-- The flow response includes script nodes that handle WebAuthn -->
<script src="https://$PROJECT_SLUG.projects.oryapis.com/.well-known/ory/webauthn.js"></script>
The script automatically:
- Detects passkey-related form fields.
- Calls
navigator.credentials.create()for registration. - Calls
navigator.credentials.get()for authentication. - Submits the WebAuthn response back to Ory.
See Custom UI Advanced Integration for
details on using webauthn.js in custom UIs.
Manual WebAuthn integration
For more control, you can manually integrate the W3C WebAuthn API:
- Initialize a registration or login flow via Ory's API.
- Parse the WebAuthn challenge from the flow response.
- Call
navigator.credentials.create()ornavigator.credentials.get(). - Submit the credential response back to Ory.
The WebAuthn API is well-documented by the W3C and MDN:
Mobile implementation
Mobile passkey implementation requires using platform-specific APIs and Ory's JSON API endpoints. Unlike browser flows, mobile
apps don't receive the webauthn.js script and must handle credential operations manually.
Platform requirements
Both iOS and Android require configuration to associate your app with your authentication domain.
iOS Associated Domains
iOS requires an apple-app-site-association (AASA) file to create a bidirectional link between your app and your domain. This
proves that your app and domain are controlled by the same entity.
How iOS association works
- You add an Associated Domains entitlement to your app:
webcredentials:example.com - When your app is installed/updated, iOS downloads the AASA file from that domain (via Apple's CDN)
- When creating a passkey, iOS verifies the RP ID matches a domain in your entitlements AND the AASA file lists your app
Add the Associated Domains entitlement
Add the entitlement to your Xcode project. The domain should match your RP ID:
<!-- YourApp.entitlements -->
<?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>com.apple.developer.associated-domains</key>
<array>
<!-- Use your root domain as RP ID -->
<string>webcredentials:example.com</string>
</array>
</dict>
</plist>
For subdomain support, iOS allows wildcard entitlements:
<string>webcredentials:*.example.com</string>
This allows your app to work with any subdomain. However, the AASA file must still be served from your RP ID domain.
Serve the apple-app-site-association file
The AASA file must be hosted at https://{your-rp-id}/.well-known/apple-app-site-association. If your RP ID is example.com,
host it at:
https://example.com/.well-known/apple-app-site-association
File structure:
{
"webcredentials": {
"apps": ["ABCDE12345.com.example.yourapp"]
}
}
The value format is {Team_ID}.{Bundle_Identifier}:
- Team ID: Find in Apple Developer portal under Membership
- Bundle Identifier: Your app's bundle ID from Xcode (e.g.,
com.example.yourapp)
File requirements:
- Must be served over HTTPS with a valid TLS certificate
- Must return
Content-Type: application/json - Must be accessible without authentication
- Apple's CDN caches the file for up to 24 hours
Ory Network doesn't host apple-app-site-association files automatically. You must host this file on your own domain at the RP ID
you configured.
Apple documentation
Android Digital Asset Links
Android uses Digital Asset Links to establish trust between your app and your domain. This verification allows credential sharing between your website and app.
How Android association works
When your app requests passkey operations, Android:
- Checks the domain associated with the RP ID
- Downloads the
assetlinks.jsonfile from that domain - Verifies your app's package name and signing certificate match the file
- Grants permission for credential operations if validated
Important: Android does not support wildcard domains. The assetlinks.json file must be hosted at the exact RP ID domain.
Serve the assetlinks.json file
The file must be hosted at https://{your-rp-id}/.well-known/assetlinks.json. If your RP ID is example.com, host it at:
https://example.com/.well-known/assetlinks.json
File structure:
[
{
"relation": ["delegate_permission/common.handle_all_urls", "delegate_permission/common.get_login_creds"],
"target": {
"namespace": "android_app",
"package_name": "com.example.yourapp",
"sha256_cert_fingerprints": [
"14:6D:E9:83:C5:73:06:50:D8:EE:B9:95:2F:34:FC:64:16:A0:83:42:E6:1D:BE:A8:8A:04:96:B2:3F:CF:44:E5"
]
}
}
]
Key relation for passkeys: delegate_permission/common.get_login_creds enables credential sharing between your website and
app.
Generate SHA-256 certificate fingerprint
For debug builds:
keytool -list -v -keystore ~/.android/debug.keystore -alias androiddebugkey -storepass android -keypass android
For release builds:
keytool -list -v -keystore /path/to/your-release-key.keystore
Copy the SHA-256 fingerprint from the output.
File requirements:
- Must be served over HTTPS with a valid TLS certificate
- Must return
Content-Type: application/json - Must be accessible without authentication
- Package name must match your app's
applicationIdinbuild.gradle - SHA-256 fingerprint must match your app's signing key
Ory Network doesn't host assetlinks.json files automatically. You must host this file on your own domain at the RP ID you
configured.
Subdomain handling
Unlike iOS, Android does not support wildcard associations. If you need passkeys on multiple subdomains:
- Use your root domain as the RP ID (recommended)
- Host the
assetlinks.jsonat the root domain - All subdomains will share the same passkey credentials
Example: With RP ID example.com, passkeys work on:
https://example.comhttps://login.example.comhttps://app.example.com
All using the single assetlinks.json file at https://example.com/.well-known/assetlinks.json.
Validation tools
Test your assetlinks.json configuration:
- Statement List Generator and Tester
- Command line:
curl https://example.com/.well-known/assetlinks.json
Android documentation
iOS implementation
iOS passkey support uses the AuthenticationServices framework. Here's how to integrate with Ory's API.
Registration flow
Initialize the registration flow:
func initializeRegistrationFlow() async throws -> FlowResponse {
var request = URLRequest(url: URL(string: "\(oryBaseURL)/self-service/registration/api")!)
request.httpMethod = "GET"
request.setValue("application/json", forHTTPHeaderField: "Accept")
let (data, response) = try await URLSession.shared.data(for: request)
// Handle response...
return try decodeFlowResponse(from: data)
}
Parse the WebAuthn challenge by extracting the challenge from the passkey_create_data node:
func extractRegistrationChallenge(_ flow: FlowResponse) -> String? {
guard let ui = flow.raw["ui"] as? [String: Any],
let nodes = ui["nodes"] as? [[String: Any]] else {
return nil
}
// Find the passkey_create_data node
for node in nodes {
guard let attributes = node["attributes"] as? [String: Any],
let name = attributes["name"] as? String,
name == "passkey_create_data" else {
continue
}
// Parse the nested JSON value
if let valueStr = attributes["value"] as? String,
let valueData = valueStr.data(using: .utf8),
let json = try? JSONSerialization.jsonObject(with: valueData) as? [String: Any],
let credentialOptions = json["credentialOptions"] as? [String: Any],
let publicKey = credentialOptions["publicKey"] as? [String: Any],
let challenge = publicKey["challenge"] as? String {
return challenge
}
}
return nil
}
The passkey_create_data node contains a JSON string with this structure:
{
"credentialOptions": {
"publicKey": {
"challenge": "base64url-encoded-challenge",
"rp": { "name": "Your App", "id": "ory.your-custom-domain.com" },
"user": { "id": "base64url-user-id", "name": "", "displayName": "" },
"pubKeyCredParams": [
{ "type": "public-key", "alg": -7 },
{ "type": "public-key", "alg": -257 }
],
"authenticatorSelection": {
"userVerification": "required",
"residentKey": "required"
}
}
},
"displayNameFieldName": "traits.email"
}
Create the passkey:
func signUpWith(userName: String, challenge: Data, domain: String) {
let publicKeyCredentialProvider = ASAuthorizationPlatformPublicKeyCredentialProvider(
relyingPartyIdentifier: domain
)
let userID = Data(UUID().uuidString.utf8)
let registrationRequest = publicKeyCredentialProvider.createCredentialRegistrationRequest(
challenge: challenge,
name: userName,
userID: userID
)
let authController = ASAuthorizationController(authorizationRequests: [registrationRequest])
authController.delegate = self
authController.presentationContextProvider = self
authController.performRequests()
}
When the user completes registration, format and submit the credential:
func submitRegistration(credential: ASAuthorizationPlatformPublicKeyCredentialRegistration) async throws {
let credentialDict: [String: Any] = [
"id": credential.credentialID.base64URLEncodedString(),
"rawId": credential.credentialID.base64URLEncodedString(),
"type": "public-key",
"response": [
"clientDataJSON": credential.rawClientDataJSON.base64URLEncodedString(),
"attestationObject": credential.rawAttestationObject?.base64URLEncodedString() ?? ""
]
]
let credentialJSON = try JSONSerialization.data(withJSONObject: credentialDict)
let credentialString = String(data: credentialJSON, encoding: .utf8)!
var request = URLRequest(url: URL(string: "\(oryBaseURL)/self-service/registration?flow=\(flowId)")!)
request.httpMethod = "POST"
request.setValue("application/json", forHTTPHeaderField: "Content-Type")
let payload: [String: Any] = [
"method": "passkey",
"passkey_register": credentialString,
"traits": [
"email": userName // Or other identity traits
]
]
request.httpBody = try JSONSerialization.data(withJSONObject: payload)
let (data, response) = try await URLSession.shared.data(for: request)
// Handle response...
}
Login flow
Initialize the login flow:
func initializeLoginFlow() async throws -> FlowResponse {
var request = URLRequest(url: URL(string: "\(oryBaseURL)/self-service/login/api")!)
request.httpMethod = "GET"
request.setValue("application/json", forHTTPHeaderField: "Accept")
let (data, response) = try await URLSession.shared.data(for: request)
return try decodeFlowResponse(from: data)
}
Extract the challenge from the passkey_challenge node:
func extractLoginChallenge(_ flow: FlowResponse) -> String? {
guard let ui = flow.raw["ui"] as? [String: Any],
let nodes = ui["nodes"] as? [[String: Any]] else {
return nil
}
// Find the passkey_challenge node
for node in nodes {
guard let attributes = node["attributes"] as? [String: Any],
let name = attributes["name"] as? String,
name == "passkey_challenge" else {
continue
}
// Parse the JSON value
if let valueStr = attributes["value"] as? String,
let valueData = valueStr.data(using: .utf8),
let json = try? JSONSerialization.jsonObject(with: valueData) as? [String: Any],
let publicKey = json["publicKey"] as? [String: Any],
let challenge = publicKey["challenge"] as? String {
return challenge
}
}
return nil
}
The passkey_challenge node contains a JSON string with this structure:
{
"publicKey": {
"challenge": "base64url-encoded-challenge",
"rpId": "ory.your-custom-domain.com",
"allowCredentials": [],
"userVerification": "required"
}
}
Authenticate with passkey:
func signInWith(challenge: Data, domain: String) {
let publicKeyCredentialProvider = ASAuthorizationPlatformPublicKeyCredentialProvider(
relyingPartyIdentifier: domain
)
let assertionRequest = publicKeyCredentialProvider.createCredentialAssertionRequest(
challenge: challenge
)
let authController = ASAuthorizationController(authorizationRequests: [assertionRequest])
authController.delegate = self
authController.presentationContextProvider = self
authController.performRequests()
}
Submit the assertion:
func submitLogin(credential: ASAuthorizationPlatformPublicKeyCredentialAssertion) async throws {
let credentialDict: [String: Any] = [
"id": credential.credentialID.base64URLEncodedString(),
"rawId": credential.credentialID.base64URLEncodedString(),
"type": "public-key",
"response": [
"clientDataJSON": credential.rawClientDataJSON.base64URLEncodedString(),
"authenticatorData": credential.rawAuthenticatorData.base64URLEncodedString(),
"signature": credential.signature.base64URLEncodedString(),
"userHandle": credential.userID.base64URLEncodedString()
]
]
let credentialJSON = try JSONSerialization.data(withJSONObject: credentialDict)
let credentialString = String(data: credentialJSON, encoding: .utf8)!
var request = URLRequest(url: URL(string: "\(oryBaseURL)/self-service/login?flow=\(flowId)")!)
request.httpMethod = "POST"
request.setValue("application/json", forHTTPHeaderField: "Content-Type")
let payload: [String: Any] = [
"method": "passkey",
"passkey_login": credentialString
]
request.httpBody = try JSONSerialization.data(withJSONObject: payload)
let (data, response) = try await URLSession.shared.data(for: request)
// Handle response...
}
Base64URL encoding
iOS requires Base64URL encoding (not standard Base64) for WebAuthn:
extension Data {
func base64URLEncodedString() -> String {
return self.base64EncodedString()
.replacingOccurrences(of: "+", with: "-")
.replacingOccurrences(of: "/", with: "_")
.replacingOccurrences(of: "=", with: "")
}
init?(base64URLEncoded string: String) {
var base64 = string
.replacingOccurrences(of: "-", with: "+")
.replacingOccurrences(of: "_", with: "/")
let remainder = base64.count % 4
if remainder > 0 {
base64 += String(repeating: "=", count: 4 - remainder)
}
self.init(base64Encoded: base64)
}
}
Android implementation
Android passkey support uses the Credential Manager API. The integration pattern is similar to iOS.
Dependencies
Add the Credential Manager dependency to your build.gradle:
dependencies {
implementation "androidx.credentials:credentials:1.2.0"
implementation "androidx.credentials:credentials-play-services-auth:1.2.0"
}
Registration flow
Initialize the registration flow:
suspend fun initializeRegistrationFlow(): FlowResponse {
val url = URL("$oryBaseURL/self-service/registration/api")
val connection = url.openConnection() as HttpURLConnection
connection.requestMethod = "GET"
connection.setRequestProperty("Accept", "application/json")
val response = connection.inputStream.bufferedReader().readText()
return parseFlowResponse(response)
}
Parse the WebAuthn challenge:
fun extractRegistrationChallenge(flow: FlowResponse): String? {
val ui = flow.raw["ui"] as? Map<*, *> ?: return null
val nodes = ui["nodes"] as? List<*> ?: return null
for (node in nodes) {
val nodeMap = node as? Map<*, *> ?: continue
val attributes = nodeMap["attributes"] as? Map<*, *> ?: continue
val name = attributes["name"] as? String ?: continue
if (name == "passkey_create_data") {
val valueStr = attributes["value"] as? String ?: continue
val json = JSONObject(valueStr)
val credentialOptions = json.getJSONObject("credentialOptions")
val publicKey = credentialOptions.getJSONObject("publicKey")
return publicKey.getString("challenge")
}
}
return null
}
Create the passkey:
suspend fun signUpWith(userName: String, requestJson: String, context: Context) {
val credentialManager = CredentialManager.create(context)
val request = CreatePublicKeyCredentialRequest(requestJson)
try {
val result = credentialManager.createCredential(
request = request,
context = context as Activity
) as CreatePublicKeyCredentialResponse
submitRegistration(result.registrationResponseJson)
} catch (e: CreateCredentialException) {
// Handle error
}
}
Submit the credential:
suspend fun submitRegistration(credentialJson: String) {
val url = URL("$oryBaseURL/self-service/registration?flow=$flowId")
val connection = url.openConnection() as HttpURLConnection
connection.requestMethod = "POST"
connection.setRequestProperty("Content-Type", "application/json")
connection.doOutput = true
val payload = JSONObject().apply {
put("method", "passkey")
put("passkey_register", credentialJson)
put("traits", JSONObject().apply {
put("email", userName)
})
}
connection.outputStream.write(payload.toString().toByteArray())
val response = connection.inputStream.bufferedReader().readText()
// Handle response...
}
Login flow
Initialize the login flow:
suspend fun initializeLoginFlow(): FlowResponse {
val url = URL("$oryBaseURL/self-service/login/api")
val connection = url.openConnection() as HttpURLConnection
connection.requestMethod = "GET"
connection.setRequestProperty("Accept", "application/json")
val response = connection.inputStream.bufferedReader().readText()
return parseFlowResponse(response)
}
Parse the WebAuthn challenge:
fun extractLoginChallenge(flow: FlowResponse): String? {
val ui = flow.raw["ui"] as? Map<*, *> ?: return null
val nodes = ui["nodes"] as? List<*> ?: return null
for (node in nodes) {
val nodeMap = node as? Map<*, *> ?: continue
val attributes = nodeMap["attributes"] as? Map<*, *> ?: continue
val name = attributes["name"] as? String ?: continue
if (name == "passkey_challenge") {
val valueStr = attributes["value"] as? String ?: continue
val json = JSONObject(valueStr)
val publicKey = json.getJSONObject("publicKey")
return publicKey.getString("challenge")
}
}
return null
}
Authenticate with passkey:
suspend fun signInWith(requestJson: String, context: Context) {
val credentialManager = CredentialManager.create(context)
val request = GetPublicKeyCredentialOption(requestJson)
val getCredRequest = GetCredentialRequest(listOf(request))
try {
val result = credentialManager.getCredential(
request = getCredRequest,
context = context as Activity
)
val credential = result.credential as PublicKeyCredential
submitLogin(credential.authenticationResponseJson)
} catch (e: GetCredentialException) {
// Handle error
}
}
Submit the assertion:
suspend fun submitLogin(credentialJson: String) {
val url = URL("$oryBaseURL/self-service/login?flow=$flowId")
val connection = url.openConnection() as HttpURLConnection
connection.requestMethod = "POST"
connection.setRequestProperty("Content-Type", "application/json")
connection.doOutput = true
val payload = JSONObject().apply {
put("method", "passkey")
put("passkey_login", credentialJson)
}
connection.outputStream.write(payload.toString().toByteArray())
val response = connection.inputStream.bufferedReader().readText()
// Handle response...
}
API response handling
Flow response structure
All registration and login flows return a similar structure:
{
"id": "flow-id-uuid",
"type": "api",
"expires_at": "2025-11-23T12:00:00Z",
"issued_at": "2025-11-23T11:00:00Z",
"request_url": "https://example.com/self-service/registration/api",
"ui": {
"action": "https://example.com/self-service/registration?flow=flow-id-uuid",
"method": "POST",
"nodes": [...]
}
}
Node types to parse
Registration flows contain these key nodes:
csrf_token(group: default): CSRF protection tokentraits.email(group: default): User identity traitspasskey_create_data(group: passkey): WebAuthn creation optionspasskey_register(group: passkey): Where to submit the credential
Login flows contain:
csrf_token(group: default): CSRF protection tokenidentifier(group: default): Optional username fieldpasskey_challenge(group: passkey): WebAuthn assertion optionspasskey_login(group: passkey): Where to submit the assertion
Success response
On successful authentication, Ory returns a session:
{
"session": {
"id": "session-id-uuid",
"active": true,
"expires_at": "2025-11-23T13:00:00Z",
"authenticated_at": "2025-11-23T11:00:00Z",
"authenticator_assurance_level": "aal1",
"authentication_methods": [
{
"method": "passkey",
"aal": "aal1",
"completed_at": "2025-11-23T11:00:00Z"
}
],
"identity": {
"id": "identity-id-uuid",
"schema_id": "default",
"traits": {
"email": "user@example.com"
}
}
}
}
Store the session token for authenticated requests. On mobile, use secure storage (iOS Keychain, Android Keystore).
Error handling
Common errors
Invalid WebAuthn response
{
"error": {
"id": "browser_location_change_required",
"code": 422,
"status": "Unprocessable Entity",
"reason": "Unable to parse WebAuthn response: Parse error for Registration"
}
}
This error occurs when there is incorrect Base64URL encoding (using standard Base64 instead), missing required fields in the
credential response, or malformed JSON in passkey_register or passkey_login fields.
To resolve this, verify your Base64URL encoding and ensure all required WebAuthn response fields are included.
Domain mismatch
{
"error": {
"id": "security_identity_mismatch",
"code": 400,
"status": "Bad Request",
"reason": "The request was malformed or contained invalid parameters"
}
}
This error occurs when the Associated Domains or assetlinks.json domain doesn't match Kratos rp.id, the AASA or
assetlinks.json file isn't properly served, or the application uses HTTP instead of HTTPS.
To resolve this, verify your Associated Domains entitlement matches your Kratos configuration. Test AASA file accessibility using
curl https://ory.your-custom-domain.com/.well-known/apple-app-site-association. Test assetlinks.json using
curl https://ory.your-custom-domain.com/.well-known/assetlinks.json. Ensure HTTPS with valid certificate.
Flow expired
{
"error": {
"id": "self_service_flow_expired",
"code": 410,
"status": "Gone",
"reason": "The self-service flow has expired"
}
}
This error occurs when the user took too long to complete the flow (default: 1 hour).
To resolve this, initialize a new flow and retry the operation.
User canceled
On mobile platforms, users can cancel the passkey prompt. Handle this gracefully.
- iOS
- Android
func authorizationController(controller: ASAuthorizationController,
didCompleteWithError error: Error) {
if let authError = error as? ASAuthorizationError,
authError.code == .canceled {
// User canceled - show alternative login options
}
}
catch (e: GetCredentialException) {
when (e) {
is GetCredentialCancellationException -> {
// User canceled - show alternative login options
}
}
}
Mobile-specific issues
AASA file not found on iOS
The passkey prompt doesn't appear or the user sees "No credentials available" errors.
To troubleshoot this issue:
- Verify the AASA file is accessible via browser.
- Check the file has correct
Content-Type: application/json. - Verify the Team ID and Bundle ID are correct.
- Try uninstalling and reinstalling the app.
- Check the device isn't using a VPN that blocks the domain.
assetlinks.json validation failed on Android
The user sees "No credentials found" errors or the passkey dialog doesn't show.
To troubleshoot this issue:
- Verify the
assetlinks.jsonfile is accessible via browser. - Use the Statement List Generator and Tester to validate.
- Verify the SHA-256 fingerprint matches your signing key.
- Ensure the package name matches exactly.
- Clear app data and retry.
Domain not HTTPS
Both iOS and Android require HTTPS for passkeys. HTTP domains fail silently or with cryptic errors.
To resolve this, use HTTPS with a valid TLS certificate. For local development, use a tool like ngrok to create an HTTPS tunnel.
Best practices
Session management
Store session tokens securely using iOS Keychain or Android Keystore. Handle session expiration by checking session validity
before making authenticated requests. Implement token refresh using Ory's /sessions/whoami endpoint to verify sessions.
User experience
Provide fallback options to allow users to sign in with other methods if passkeys fail. Handle errors gracefully by showing user-friendly error messages. Support AutoFill on iOS 17+ and Android 14+ to enable AutoFill-assisted passkey sign-in for better user experience.
Testing
Test on physical devices as passkeys don't work reliably in simulators or emulators. Test with multiple accounts to verify credential isolation. Test cross-platform to ensure passkeys created on one platform work on others via cloud sync.
Next steps
Review the Passkeys overview for configuration options. See Self-Service Flows for more flow details. Check out the API documentation for complete endpoint reference.