diff --git a/docs/kratos/emails-sms/05_custom-email-templates.mdx b/docs/kratos/emails-sms/05_custom-email-templates.mdx
index 2c542899dd..06fa7ff3c9 100644
--- a/docs/kratos/emails-sms/05_custom-email-templates.mdx
+++ b/docs/kratos/emails-sms/05_custom-email-templates.mdx
@@ -63,7 +63,7 @@ To learn more about the verification flow, read
When you enable the one-time code method the login flow will need to send out an email to users signing in through the one-time
code method. The system will then use the `login_code.valid` template to send the login code to the user.
-To learn more about login via a one-time code, read the [one-time code](../passwordless/06_code.mdx) documentation.
+To learn more about login via a one-time code, read the [one-time code](../passwordless/07_code.mdx) documentation.
```mdx-code-block
@@ -74,7 +74,7 @@ To learn more about login via a one-time code, read the [one-time code](../passw
When you enable the one-time code method the registration flow will need to send out an email to users signing up using the
one-time code method. The system will then use the `registration_code.valid` template to send the registration code to the user.
-To learn more about registration via a one-time code, read the [one-time code](../passwordless/06_code.mdx) documentation.
+To learn more about registration via a one-time code, read the [one-time code](../passwordless/07_code.mdx) documentation.
```mdx-code-block
diff --git a/docs/kratos/passwordless/05_passkeys.mdx b/docs/kratos/passwordless/05_passkeys.mdx
index 79c3c3674c..6d2dcad5c1 100644
--- a/docs/kratos/passwordless/05_passkeys.mdx
+++ b/docs/kratos/passwordless/05_passkeys.mdx
@@ -148,6 +148,18 @@ is defined, the passkey strategy will not work.
}
```
+### Implementing passkeys in your application
+
+After configuring passkeys, you need to integrate them into your application. The implementation differs depending on your
+platform:
+
+- Web applications can use Ory's `webauthn.js` helper script or manually integrate the WebAuthn API.
+- Mobile applications (iOS and Android) require platform-specific credential management APIs and direct integration with Ory's
+ JSON API endpoints.
+
+For detailed implementation instructions, code examples, and best practices for both web and mobile platforms, see
+[Implementing passkeys for web and mobile](./06_passkeys-mobile.mdx).
+
## Passkeys with the WebAuthN strategy
### Configuration
diff --git a/docs/kratos/passwordless/06_passkeys-mobile.mdx b/docs/kratos/passwordless/06_passkeys-mobile.mdx
new file mode 100644
index 0000000000..c12242d881
--- /dev/null
+++ b/docs/kratos/passwordless/06_passkeys-mobile.mdx
@@ -0,0 +1,940 @@
+---
+id: passkeys-mobile
+title: Implement passkey authentication in web and mobile applications
+sidebar_label: Passkeys for mobile
+slug: passkeys-mobile-web-implementation
+---
+
+import Tabs from "@theme/Tabs"
+import TabItem from "@theme/TabItem"
+
+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](./05_passkeys.mdx) for initial setup instructions.
+
+:::note
+
+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.
+
+```html
+
+
+```
+
+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](../bring-your-own-ui/custom-ui-advanced-integration#passwordless-authentication.mdx) for
+details on using `webauthn.js` in custom UIs.
+
+### Manual WebAuthn integration
+
+For more control, you can manually integrate the [W3C WebAuthn API](https://www.w3.org/TR/webauthn-2/):
+
+1. Initialize a registration or login flow via Ory's API.
+2. Parse the WebAuthn challenge from the flow response.
+3. Call `navigator.credentials.create()` or `navigator.credentials.get()`.
+4. Submit the credential response back to Ory.
+
+The WebAuthn API is well-documented by the W3C and MDN:
+
+- [W3C WebAuthn Specification](https://www.w3.org/TR/webauthn-2/)
+- [MDN Web Authentication API](https://developer.mozilla.org/en-US/docs/Web/API/Web_Authentication_API)
+
+## 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
+
+1. You add an Associated Domains entitlement to your app: `webcredentials:example.com`
+2. When your app is installed/updated, iOS downloads the AASA file from that domain (via Apple's CDN)
+3. 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:
+
+```xml
+
+
+
+
+
+ com.apple.developer.associated-domains
+
+
+ webcredentials:example.com
+
+
+
+```
+
+**For subdomain support**, iOS allows wildcard entitlements:
+
+```xml
+webcredentials:*.example.com
+```
+
+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:**
+
+```json
+{
+ "webcredentials": {
+ "apps": ["ABCDE12345.com.example.yourapp"]
+ }
+}
+```
+
+The value format is `{Team_ID}.{Bundle_Identifier}`:
+
+- **Team ID**: Find in [Apple Developer portal](https://developer.apple.com/account) 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
+
+:::warning
+
+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
+
+- [Supporting Associated Domains](https://developer.apple.com/documentation/xcode/supporting-associated-domains)
+- [About the apple-app-site-association file](https://developer.apple.com/documentation/bundleresources/applinks/details/supporting_associated_domains)
+
+### 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:
+
+1. Checks the domain associated with the RP ID
+2. Downloads the `assetlinks.json` file from that domain
+3. Verifies your app's package name and signing certificate match the file
+4. 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:**
+
+```json
+[
+ {
+ "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:
+
+```shell
+keytool -list -v -keystore ~/.android/debug.keystore -alias androiddebugkey -storepass android -keypass android
+```
+
+For release builds:
+
+```shell
+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 `applicationId` in `build.gradle`
+- SHA-256 fingerprint must match your app's signing key
+
+:::warning
+
+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:
+
+1. Use your root domain as the RP ID (recommended)
+2. Host the `assetlinks.json` at the root domain
+3. All subdomains will share the same passkey credentials
+
+**Example:** With RP ID `example.com`, passkeys work on:
+
+- `https://example.com`
+- `https://login.example.com`
+- `https://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](https://developers.google.com/digital-asset-links/tools/generator)
+- Command line: `curl https://example.com/.well-known/assetlinks.json`
+
+#### Android documentation
+
+- [Android Credential Manager](https://developer.android.com/training/sign-in/passkeys)
+- [Digital Asset Links](https://developers.google.com/digital-asset-links/v1/getting-started)
+- [Verify Android App Links](https://developer.android.com/training/app-links/verify-android-applinks)
+
+### iOS implementation
+
+iOS passkey support uses the [AuthenticationServices framework](https://developer.apple.com/documentation/authenticationservices).
+Here's how to integrate with Ory's API.
+
+#### Registration flow
+
+Initialize the registration flow:
+
+```swift
+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:
+
+```swift
+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:
+
+```json
+{
+ "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:
+
+```swift
+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:
+
+```swift
+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:
+
+```swift
+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:
+
+```swift
+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:
+
+```json
+{
+ "publicKey": {
+ "challenge": "base64url-encoded-challenge",
+ "rpId": "ory.your-custom-domain.com",
+ "allowCredentials": [],
+ "userVerification": "required"
+ }
+}
+```
+
+Authenticate with passkey:
+
+```swift
+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:
+
+```swift
+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:
+
+```swift
+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](https://developer.android.com/training/sign-in/passkeys). The
+integration pattern is similar to iOS.
+
+#### Dependencies
+
+Add the Credential Manager dependency to your `build.gradle`:
+
+```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:
+
+```kotlin
+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:
+
+```kotlin
+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:
+
+```kotlin
+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:
+
+```kotlin
+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:
+
+```kotlin
+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:
+
+```kotlin
+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:
+
+```kotlin
+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:
+
+```kotlin
+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:
+
+```json
+{
+ "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 token
+- `traits.email` (group: default): User identity traits
+- `passkey_create_data` (group: passkey): WebAuthn creation options
+- `passkey_register` (group: passkey): Where to submit the credential
+
+Login flows contain:
+
+- `csrf_token` (group: default): CSRF protection token
+- `identifier` (group: default): Optional username field
+- `passkey_challenge` (group: passkey): WebAuthn assertion options
+- `passkey_login` (group: passkey): Where to submit the assertion
+
+### Success response
+
+On successful authentication, Ory returns a session:
+
+```json
+{
+ "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
+
+```json
+{
+ "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
+
+```json
+{
+ "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
+
+```json
+{
+ "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.
+
+```mdx-code-block
+
+
+```
+
+```swift
+func authorizationController(controller: ASAuthorizationController,
+ didCompleteWithError error: Error) {
+ if let authError = error as? ASAuthorizationError,
+ authError.code == .canceled {
+ // User canceled - show alternative login options
+ }
+}
+```
+
+```mdx-code-block
+
+
+```
+
+```kotlin
+catch (e: GetCredentialException) {
+ when (e) {
+ is GetCredentialCancellationException -> {
+ // User canceled - show alternative login options
+ }
+ }
+}
+```
+
+```mdx-code-block
+
+
+```
+
+### 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:
+
+1. Verify the AASA file is accessible via browser.
+2. Check the file has correct `Content-Type: application/json`.
+3. Verify the Team ID and Bundle ID are correct.
+4. Try uninstalling and reinstalling the app.
+5. 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:
+
+1. Verify the `assetlinks.json` file is accessible via browser.
+2. Use the [Statement List Generator and Tester](https://developers.google.com/digital-asset-links/tools/generator) to validate.
+3. Verify the SHA-256 fingerprint matches your signing key.
+4. Ensure the package name matches exactly.
+5. 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](https://ngrok.com/) 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](./05_passkeys.mdx) for configuration options. See [Self-Service Flows](../self-service.mdx) for
+more flow details. Check out the [API documentation](../reference/api.mdx) for complete endpoint reference.
diff --git a/docs/kratos/passwordless/06_code.mdx b/docs/kratos/passwordless/07_code.mdx
similarity index 100%
rename from docs/kratos/passwordless/06_code.mdx
rename to docs/kratos/passwordless/07_code.mdx
diff --git a/docs/kratos/self-service/flows/user-login.mdx b/docs/kratos/self-service/flows/user-login.mdx
index 44331fbd5f..e819c21882 100644
--- a/docs/kratos/self-service/flows/user-login.mdx
+++ b/docs/kratos/self-service/flows/user-login.mdx
@@ -59,7 +59,7 @@ Supported login methods are
- `oidc` for signing in using a social sign in provider such as Google or Facebook. Visit the
[Social Sign In guide](../../social-signin/overview).
- `passkey` for signing in with a Passkey. Visit the [Passkey guide](../../passwordless/05_passkeys.mdx).
-- `code` for signing in with a code via Email or SMS. Visit the [Code via Email / SMS guide](../../passwordless/06_code.mdx).
+- `code` for signing in with a code via Email or SMS. Visit the [Code via Email / SMS guide](../../passwordless/07_code.mdx).
- `webauthn` (legacy) for signing in with Webauthn. This method is supported for backwards compatibility, use
[Passkey](../../passwordless/05_passkeys.mdx) instead.
diff --git a/package-lock.json b/package-lock.json
index 3c1ff65f91..98234cb1ff 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -4354,9 +4354,9 @@
}
},
"node_modules/@emnapi/runtime": {
- "version": "1.8.1",
- "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.8.1.tgz",
- "integrity": "sha512-mehfKSMWjjNol8659Z8KxEMrdSJDDot5SXMq00dM8BN4o+CLNXQ0xH2V7EchNHV4RmbZLmmPdEaXZc5H2FXmDg==",
+ "version": "1.7.1",
+ "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.7.1.tgz",
+ "integrity": "sha512-PVtJr5CmLwYAU9PZDMITZoR5iAOShYREoR45EyyLrbntV50mdePTgUn4AmOw90Ifcj+x2kRjdzr1HP3RrNiHGA==",
"dev": true,
"license": "MIT",
"optional": true,
diff --git a/src/sidebar.ts b/src/sidebar.ts
index d934da6619..cf2294cfbd 100644
--- a/src/sidebar.ts
+++ b/src/sidebar.ts
@@ -359,6 +359,7 @@ const kratos: SidebarItemsConfig = [
"kratos/passwordless/passwordless",
"kratos/passwordless/one-time-code",
"kratos/passwordless/passkeys",
+ "kratos/passwordless/passkeys-mobile",
"kratos/organizations/organizations",
"kratos/emails-sms/custom-email-templates",
],