Native apps (iOS + Android)

Native passkeys on iOS and Android only work when the app declares an Associated Domain (iOS) or App Link (Android) for your WebAuthn RP ID, and that domain serves the matching .well-known files. This guide walks through both sides for an Expo app paired with a SvelteKit (or any other) frontend.

What you’ll wire up

SideWhat
Native appExpo auth client + iOS Associated Domains + Android intents
App originapple-app-site-association + assetlinks.json

Use the Expo client

Import the client from @robelest/convex-auth/expo in native apps. This entrypoint uses Expo-native defaults: SecureStore token persistence, expo-auth-session OAuth launching, and native passkey support.

import { api } from "../convex/_generated/api";
import { client } from "@robelest/convex-auth/expo";

import { convex } from "./convex";

export const auth = client({
  convex,
  api: api.auth,
  authSession: {
    scheme: "myapp",
    redirectUri: "myapp://auth",
  },
});

Do not use @robelest/convex-auth/browser in native Expo code. The Expo entrypoint falls back to the browser client automatically when running on web.

iOS

1. Get your Team ID and Bundle ID

  • Team ID: Apple Developer → Membership → Team ID (10-character string).
  • Bundle ID: From app.config.js / app.jsonios.bundleIdentifier. Combined as TEAMID.com.example.app.

2. Add the Associated Domain to your Expo config

// app.config.js
const passkeyDomain = process.env.SITE_URL ? new URL(process.env.SITE_URL).hostname : null;

module.exports = {
  expo: {
    ios: {
      bundleIdentifier: "com.example.app",
      ...(passkeyDomain ? { associatedDomains: [`webcredentials:${passkeyDomain}`] } : {}),
    },
  },
};

For local development, append ?mode=developer to bypass AASA verification (iOS 17.4+):

associatedDomains: [`webcredentials:${passkeyDomain}?mode=developer`],

3. Serve AASA from the app origin

Set IOS_APP_IDS on the Convex deployment or on the frontend/edge host that serves /.well-known/*:

IOS_APP_IDS="ABC123DEF.com.example.app"

auth.http() serves the route automatically from Convex. If your frontend owns the app origin, proxy /.well-known/apple-app-site-association to Convex or adapt the same helper in your framework route:

// src/routes/.well-known/apple-app-site-association/+server.ts
import { wellKnown } from "@robelest/convex-auth/server";

export const GET = () => {
  const r = wellKnown("apple-app-site-association");
  if (r === null) return new Response(null, { status: 404 });
  return new Response(r.body, { status: r.status, headers: r.headers });
};

4. Rebuild and verify

pnpm expo prebuild --clean
pnpm expo run:ios --device

Verify Apple’s CDN sees the file:

curl https://app-site-association.cdn-apple.com/a/v1/<your-domain>

In iOS Settings, search for your app — Associated Domains should list your host. Pressing the passkey button should now show the native iOS sheet.

Android

1. Get your SHA-256 fingerprint

For debug builds:

cd android && ./gradlew signingReport

For release builds, use the fingerprint from Google Play Console → Setup → App signing.

2. Add intent filters to Expo config

// app.config.js
module.exports = {
  expo: {
    android: {
      package: "com.example.app",
      ...(passkeyDomain
        ? {
            intentFilters: [
              {
                action: "VIEW",
                autoVerify: true,
                data: [{ scheme: "https", host: passkeyDomain }],
                category: ["BROWSABLE", "DEFAULT"],
              },
            ],
          }
        : {}),
    },
  },
};

3. Serve assetlinks.json from the app origin

Set ANDROID_APP_LINKS:

ANDROID_APP_LINKS="com.example.app:AA:BB:CC:DD:EE:FF:..."

auth.http() serves the route automatically from Convex. If your frontend owns the app origin, proxy /.well-known/assetlinks.json to Convex or adapt the same helper in your framework route:

// src/routes/.well-known/assetlinks.json/+server.ts
import { wellKnown } from "@robelest/convex-auth/server";

export const GET = () => {
  const r = wellKnown("assetlinks.json");
  if (r === null) return new Response(null, { status: 404 });
  return new Response(r.body, { status: r.status, headers: r.headers });
};

4. Verify

Use Google’s validator:

https://digitalassetlinks.googleapis.com/v1/statements:list?source.web.site=https://<host>&relation=delegate_permission/common.get_login_creds

Trigger Credential Manager from a real Android device — the passkey sheet should appear.

Choosing the RP ID

Your WebAuthn RP ID must match the AASA / assetlinks.json host. The convex-auth passkey() provider derives the RP ID from SITE_URL by default:

SITE_URLRP ID
https://app.example.comapp.example.com
http://localhost:5173localhost

You can override with passkey({ rpId: "example.com" }) if you want passkeys to work across subdomains (the AASA / assetlinks.json must then live at example.com).

Troubleshooting

  • Native sheet doesn’t appear, error like “Passkey sign-in failed”: Most likely the Associated Domain entitlement is missing or AASA isn’t reachable. Run curl https://app-site-association.cdn-apple.com/a/v1/<host>.
  • “webauthn: not supported”: Check Passkey.isSupported() in the react-native-passkey library — needs iOS 16+ / Android 14+ / Play Services.
  • Counter validation failures: Test on a fresh device — emulators have spotty Credential Manager support.

Related