npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

@moon-x/react-native-sdk

v0.21.0

Published

Headless MoonX React Native SDK with WebView-backed transport, native passkeys, and app-owned UI.

Readme

@moon-x/react-native-sdk

Headless React Native SDK for MoonX. Applications own their authentication, wallet, funding, connection, and signing UI; the SDK provides hooks, native ceremonies, session state, wallet operations, and a WebView-backed trust boundary.

Install

# bare RN
pnpm add @moon-x/react-native-sdk \
  react-native-webview react-native-passkeys \
  @react-native-async-storage/async-storage \
  react-native-get-random-values react-native-url-polyfill \
  buffer \
  viem bs58

# Expo
pnpm add @moon-x/react-native-sdk \
  react-native-webview react-native-passkeys \
  @react-native-async-storage/async-storage \
  react-native-get-random-values react-native-url-polyfill \
  buffer \
  expo-web-browser expo-linking \
  viem bs58

The SDK declares everything except @moon-x/core as peer dependencies. Privy follows the same pattern; their docs are the canonical install reference and apply 1:1 here:

  • https://docs.privy.io/basics/react-native/installation
  • https://docs.privy.io/basics/react-native/advanced/setup-passkeys

Polyfills (order matters)

The SDK touches several globals that RN doesn't ship in every config. Import the polyfills at the top of your app entry, before any other code, so they're set up before anything else loads:

// index.js / index.ts — must run first.

// crypto.getRandomValues — needed by passkey challenge generation,
// MPC keygen, and viem.
import "react-native-get-random-values";

// URL / URLSearchParams — needed by viem and the OAuth deep-link
// helpers.
import "react-native-url-polyfill/auto";

// Buffer — needed by bs58, ethers, viem's RLP path, and the SDK's
// Solana broadcast helper. RN does not provide a Buffer global; on
// Expo it's polyfilled implicitly but you should set it explicitly so
// bare-RN consumers and any later eject-from-Expo users don't break.
// Same pattern Privy documents.
import { Buffer } from "buffer";
(global as any).Buffer = (global as any).Buffer || Buffer;

import { registerRootComponent } from "expo";
import App from "./App";
registerRootComponent(App);

atob / btoa and TextEncoder are built into Hermes (RN's default JS engine since 0.71). The peerDep react-native: ">=0.78.0" covers this — but if you're targeting an older runtime or have explicitly opted into JSC, add text-encoding and base-64 polyfills here too.

Provider

import { MoonXProvider } from "@moon-x/react-native-sdk";

export default function Root() {
  return (
    <MoonXProvider
      publishableKey={process.env.EXPO_PUBLIC_MOONX_PUBLISHABLE_KEY!}
      config={{
        iframeUrl: "https://x-iframe.moonpay.com",
        oauthRedirectUri: "myapp://oauth-callback",
      }}
    >
      <App />
    </MoonXProvider>
  );
}

config.iframeUrl is required and explicit — the RN SDK never reads env vars to discover it. Use the same URL the web SDK points at. The iframe-app detects WebView mode at runtime via window.ReactNativeWebView and routes replies through the bridge automatically; no iframe-app changes needed per integration.

Security

Every sensitive op (sign / send / create / import / export / addPasskey / removePasskey) drives the same server-verified presence ceremony per call:

  1. Server-issued WebAuthn challenge.
  2. Fresh biometric assertion (stripping response.userHandle before anything crosses the bridge to MoonX — DEK hygiene).
  3. Server mints short-lived (30s) single-use JWTs scoped to the specific endpoint set this op needs.
  4. Each scoped JWT travels as X-MoonX-Presence on its matching gated endpoint and is burned (platform.app_presence_jti_used) on first use.

What this closes: captured userHandle + session JWT alone no longer unlocks DEK material — every gated endpoint also needs a fresh WebAuthn signature MoonX verifies against the credential's stored public key. See apps/platform/docs/notes/passkeys/presence-tokens.md in the backend repo for the full threat model.

The previously-configurable config.security.assertionCacheTtlMs and per-call requireFreshAssertion flag were removed entirely when presence-token gating shipped — they are no longer on the public TypeScript surface. Every op is always-fresh by construction.

Hooks

Authentication & user state

| Hook | What it does | |---|---| | useMoonX() | The active headless SDK instance: { ready, isAuthenticated, user, logout, getSessionTokens, refreshUser, ... }. Web-only start, modal, and appearance methods are intentionally absent. | | useUser() | Just { user, refreshUser }. Re-subscribes to user changes. | | useLoginWithEmail({ onComplete?, onError? }) | Headless email-OTP. Returns { state, sendCode, loginWithCode, reset }. state is a discriminated union: idle / sending / awaiting-code / verifying / complete / error. | | useLoginWithOAuth() | Google + Apple flows via expo-web-browser. Returns { state, loginWithOAuth, reset }. | | useLogout() | { logout } — also clears AsyncStorage + WebView session. | | useAttachOAuth() / useDetachOAuth() | Link / unlink an OAuth provider on an existing user. |

getSessionTokens() returns { accessToken, identityToken, expiresAt } or null. Refresh tokens remain inside the WebView and are never exposed to the host application.

getCurrentSession() returns the current session or null when the WebView authoritatively reports that no session exists. It rejects on bridge/transport failures. Callers that poll it directly should catch those failures and retain their last-known auth state rather than treating an unavailable WebView as a logout. This distinction was introduced with @moon-x/core 0.15.0.

MoonPay Consumer session exchange

Use loginWithMoonPayCustomerToken when the native application already has a MoonPay Consumer customerToken. The SDK exchanges the token for a MoonX session. The WebView stores the session credentials. The method returns only userCreated.

import {
  MOONPAY_CONSUMER_SESSION_ERROR_CODES,
  type MoonPayConsumerSessionError,
  useMoonX,
} from "@moon-x/react-native-sdk";

const { loginWithMoonPayCustomerToken } = useMoonX();

try {
  const { userCreated } = await loginWithMoonPayCustomerToken({
    customerToken,
  });
} catch (error) {
  const code = (error as MoonPayConsumerSessionError).error_code;
  if (code === MOONPAY_CONSUMER_SESSION_ERROR_CODES.SESSION_INVALID) {
    // Get a new Consumer token and try again.
  }
}

The promise resolves after the MoonX session is stored. At that time, isAuthenticated is true. The SDK then loads the user profile in the background. Thus, user can be null for a short time. It can also stay null if the profile request fails. Use refreshUser when the application must have the profile.

The WebView uses its configured publishable key for the backend exchange. It rejects the request if the key in the native message does not match that key. A 401 response from the backend becomes the stable moonpay_consumer_session_invalid error code. Do not parse HTTP status numbers from error messages.

The provider revalidates its hidden-WebView session on cold start and whenever the native app returns to the foreground. revalidatedAt advances after each probe so shared wallet hooks refresh app-owned wallet state.

Passkeys

| Hook | What it does | |---|---| | usePasskeyStatus() | { hasPasskey, hasBackupEligiblePasskey, passkeys, loading, error, refresh }. Each passkey includes provider metadata plus backup_eligible / backup_state recovery signals when available. | | useRegisterPasskey() | First-time passkey enrollment via the native ceremony (iOS ASAuthorization, Android Credential Manager). Pass createWallets to create the user's first Ethereum, Solana, and/or Tron wallets from the same prompt. | | useAddPasskey() | Add an additional passkey to an authenticated user. | | useRemovePasskey() | Remove a passkey by its credential ID. |

const { registerPasskey } = useRegisterPasskey();
const { wallets = [], backup_eligible } = await registerPasskey({
  createWallets: [
    { chain: "ethereum" },
    { chain: "solana" },
    { chain: "tron" },
  ],
});

if (backup_eligible === false) {
  // The credential is device-bound. Wallet creation is still allowed for
  // backwards compatibility, but warn the user to add a syncing passkey.
}

The combined path is for a user with no existing passkey and no wallet. If the backend cannot mint the complete short-lived token set, passkey registration still succeeds and wallets contains only confirmed creations. Offer createWallet for each missing chain from a fresh press event.

Wallet creation does not require a backup-eligible passkey; this preserves the existing standalone createWallet behavior and supports intentional hardware-key/device-bound setups. However, when backup_eligible is false, the credential cannot sync to another device. If it is the user's only passkey, losing that authenticator can permanently remove access to the wallet. Show an explicit recovery warning and offer addPasskey with a syncing provider such as iCloud Keychain, Google Password Manager, 1Password, or Bitwarden.

createWallets is runtime-validated before the OS passkey prompt. It accepts only ethereum, solana, and tron, and the requested token total must fit the backend's 64-token ceremony limit.

Wallets

useMoonX().createWallet(chain) always creates a new wallet, including concurrent calls. useMoonX().findOrCreateWallet(chain) returns the first existing wallet for that chain, or creates one if none exists. Both return { wallet }. For Bitcoin, pass { addressType } to find or create a wallet of that type (p2tr by default). findOrCreateWallet() rejects with wallets_unavailable rather than creating a wallet when the existing list can't be read.

Only findOrCreateWallet() shares concurrent requests. Sharing requires the same transport, publishable key, login, and chain (and address type for Bitcoin). Separate devices and SDK transports can still race. A routine access-token refresh does not interrupt the operation; the helper rejects with session_changed only if a different login replaces the session while it runs. If a different login replaces the session during creation, the wallet can already exist on the earlier login when the call rejects. After session_changed, confirm the intended account before you retry with findOrCreateWallet(), not createWallet(). The retry searches the current account before it creates a wallet. If the new login is the same account, the retry finds the wallet that the first call created.

The generic useCreateWallet() hook also returns both methods. Existing createWallet() callers need no new option.

| Hook | What it does | |---|---| | useWallets(walletType) | Generic chain-aware list hook for Ethereum, Solana, or Tron. Chain subpaths provide pinned useWallets() variants. | | useCreateWallet() | Generic top-level lifecycle hook; chain subpaths provide pinned variants. | | useImportKey() | Bring-your-own-key flow. | | useExportKey() | Show the user their private key via the visible-WebView modal pattern (see below). Plaintext never leaves the WebView's DOM. | | useEphemeralSigner() | Manage the user's WaaS ephemeral signers (SES) end-to-end. Returns { signers, isLoading, refresh, provision, revoke, lastProvisioned, error, reset, ... }. Auto-refreshes the list after provision/revoke. |

The main entry and chain subpaths deliberately use different wallet-lifecycle call shapes. The main entry is generic and takes an explicit chain; subpaths pin the chain and match the web SDK facades:

| Import | Call | Result | |---|---|---| | Main entry | importKey(walletType, key) | { wallet: PublicWallet } | | /ethereum, /solana, /tron | importKey(key) | PublicWallet |

| Export | Call | |---|---| | Main entry | exportKey(wallet) | | /ethereum, /solana, /tron | exportKey({ wallet }) |

import {
  useExportKey as useGenericExportKey,
  useImportKey as useGenericImportKey,
} from "@moon-x/react-native-sdk";
import {
  useExportKey as useEthereumExportKey,
  useImportKey as useEthereumImportKey,
} from "@moon-x/react-native-sdk/ethereum";

const { importKey: importGeneric } = useGenericImportKey();
const { exportKey: exportGeneric } = useGenericExportKey();
const { importKey: importEthereum } = useEthereumImportKey();
const { exportKey: exportEthereum } = useEthereumExportKey();

const { wallet } = await importGeneric("ethereum", privateKey);
await exportGeneric(wallet);

const ethereumWallet = await importEthereum(privateKey);
await exportEthereum({ wallet: ethereumWallet });

Identity-backed encryption

useMoonX() exposes enableAccountRecovery() and disableAccountRecovery().

| State | enableAccountRecovery() | |---|---| | No DEK | Creates the identity-backed wrap using the signed-in session. No passkey prompt or OTP. | | Usable identity-backed wrap exists | Resolves without another ceremony. | | Existing passkey-protected DEK | Uses a native passkey assertion to add the identity-backed wrap. | | App configuration is unavailable | Rejects with account_recovery_unavailable. |

The host app owns the disclosure before adding identity-backed access to an existing wallet. RN does not show the web SDK consent modal. disableAccountRecovery() requires a passkey assertion because it removes the identity-backed access path.

Both methods use the configured relyingPartyOrigin when they need a passkey assertion. Session-only setup does not require that origin.

Add a passkey after identity-backed setup

registerPasskey() and addPasskey() can add the first passkey to an existing identity-backed wallet. This upgrade requires email OTP presence before native registration. The SDK then stores a passkey wrap for the existing DEK. It does not replace the DEK or create another wallet.

Register the host app OTP UI with registerPresenceOtpCodeProvider, exported from @moon-x/react-native-sdk. The provider receives a PresenceOtpCodeRequest and returns a promise for the entered code. Reject the promise when the user cancels. Call the returned cleanup function when the provider is no longer needed.

The OTP path requires a verified email address. Initial identity-backed setup and eligible ephemeral-signer actions do not require this OTP provider. registerPasskey({ createWallets: ... }) rejects a KMS-only upgrade with register_create_unavailable_for_kms_upgrade.

If a previous attempt registered a passkey but failed to store its wrap, the SDK can repair that wrap using the existing credential.

Ephemeral signer list status

useEphemeralSigner().signers includes non-revoked signers with status active, needs_reauth, or expired. Expired signers remain visible and can be revoked. Provision a fresh signer for needs_reauth or expired.

Older backends can omit status. The SDK preserves their responses. Inspect expires_at and handle signing errors instead of treating a missing status as proof that a signer is usable.

Before the backend starts returning expired, update exhaustive status handling and runtime validators in your application. Updating the SDK alone does not change application logic. Rebuild and distribute the updated mobile application to its users.

Per-chain wallets and signing — /ethereum, /solana, and /tron

Chain-aware hooks live under subpaths so they don't pull in the other chain's adapters if you only use one — same pattern as @moon-x/react-sdk:

import { useSignMessage, useSignTransaction, useSendTransaction } from "@moon-x/react-native-sdk/ethereum";
import { useSignMessage as useSignSolanaMessage } from "@moon-x/react-native-sdk/solana";
import { useGetBalance as useTronBalance } from "@moon-x/react-native-sdk/tron";

Ethereum hooks:

  • useSignMessage — EIP-191 personal_sign. Returns { signature: "0x..." }.
  • useSignTransaction — Signs an EIP-1559 tx. Returns { signature, serializedSigned, hash }.
  • useSignTypedData — EIP-712. Returns { signature: "0x..." }.
  • useSignHash — Raw ECDSA digest sign (Privy parity — secp256k1_sign).
  • useSign7702Authorization — EIP-7702 delegation auth.
  • useSendTransaction — Sign + broadcast via the configured RPC.
  • useGetBalance — Native token balance.

Solana hooks:

  • useSignMessage — Ed25519 signature.
  • useSignTransaction — Signs a serialized base58 tx. Returns { signedTransaction: Uint8Array }.
  • useSendTransaction — Sign + broadcast.
  • useGetBalance — Lamport balance.
  • useGetTokenAccounts — SPL token accounts owned by the wallet.

Tron hooks:

  • useSignMessage — TIP-191 message signing.
  • useSignTransaction — signs a TronWeb transaction JSON object.
  • useSendTransaction — signs and submits through the supplied fullnode URL.
  • useGetBalance — native TRX balance.

All three subpaths also expose chain-pinned useCreateWallet, useWallets, useImportKey, useExportKey, and useFundWallet hooks.

Sign, send, balance, and token-account results use the same wire contract as the web SDK.

Headless funding

useFundWallet returns the provider session instead of opening UI. Present the returned URL in your own native or in-app browser:

import * as WebBrowser from "expo-web-browser";
import { useFundWallet } from "@moon-x/react-native-sdk/ethereum";

const { fundWallet } = useFundWallet();
const session = await fundWallet(wallet.public_address, {
  chain: { id: 8453 },
  amount: "25",
});
await WebBrowser.openBrowserAsync(session.url);

External wallets and WalletConnect

useExternalWallets(connectors) orchestrates host-supplied native wallet connectors without rendering a picker. It exposes discovery, connection, disconnection, account switching, and the connected wallet's signing/request methods.

For WalletConnect, initialize a v2 SignClient in the host app and pass it to connectWalletConnect. The SDK returns a pairing URI and approval promise; the host owns QR/deep-link presentation:

import { connectWalletConnect } from "@moon-x/react-native-sdk";

const pairing = await connectWalletConnect({
  client,
  requiredNamespaces: {
    eip155: {
      chains: ["eip155:8453"],
      methods: ["personal_sign", "eth_sendTransaction"],
      events: ["accountsChanged", "chainChanged"],
    },
  },
});

if (pairing.uri) presentPairingUri(pairing.uri);
const wallet = await pairing.approval();
const signature = await wallet.signMessage?.("hello");

pairing.uri is optional because WalletConnect may approve from an existing pairing without returning a new URI.

Use listWalletConnectSessions and restoreWalletConnectSession to discover and restore approved sessions after a native app restart.

Mobile-specific notes

  • No SDK-owned wallet picker. External-wallet and WalletConnect primitives are headless; presentation stays in the host app.
  • useExportKey is a top-level hook on RN. On web the same operation hangs off useMoonX().exportKey() inside a parent-side modal; on RN it toggles the always-mounted WebView into a full-screen visible state so the iframe-app renders the key UI itself.

Visible-WebView pattern (sensitive flows)

useExportKey temporarily moves the always-mounted WebView from off-screen to a full-screen view so the iframe-app can render the secret-sequestration UI. The plaintext key never leaves the WebView's DOM — RN-side JS only sees the terminal status and rendered pixels.

The hook surface is still headless from the consumer's perspective:

const { exportKey } = useExportKey();
const result = await exportKey(wallet);
// { status: "completed" | "cancelled" | "failed", error?: string }

Native config

The SDK ships an Expo config plugin (app.plugin.js) that writes the iOS and Android edits described below at expo prebuild time. The plugin is Expo-only — bare React Native projects (react-native init, no expo dependency) ignore it entirely and must apply the same edits by hand. Pick the path that matches your project.

Path A: Expo (recommended)

Add the plugin to app.json:

{
  "expo": {
    "scheme": "myapp",
    "plugins": [
      ["@moon-x/react-native-sdk", {
        "passkeyDomains": ["myapp.com"],
        "oauthRedirectScheme": "myapp"
      }]
    ]
  }
}

Then run pnpm prebuild (or pnpm prebuild --clean to regenerate native code from scratch). The plugin produces:

  • iOS — webcredentials:<domain> Associated Domains entitlement for each passkeyDomains entry.
  • iOS — ITSAppUsesNonExemptEncryption = false (App Store export compliance).
  • Android — <meta-data android:name="asset_statements"> inside <application> in AndroidManifest.xml, plus a matching <string name="asset_statements"> resource in res/values/strings.xml pointing at https://<passkeyDomain>/.well-known/assetlinks.json for every entry in passkeyDomains. Required by Credential Manager — without it, [50152] RP ID cannot be validated.
  • Android — <intent-filter> on MainActivity for the oauthRedirectScheme deep link.

Re-run pnpm prebuild whenever you change the plugin options.

Path B: Bare React Native (no Expo prebuild)

The plugin does nothing here — app.json and app.plugin.js aren't consulted. Apply each edit manually:

ios/<App>/<App>.entitlements — add:

<key>com.apple.developer.associated-domains</key>
<array>
  <string>webcredentials:myapp.com</string>
</array>

Also ensure https://myapp.com/.well-known/apple-app-site-association declares webcredentials for your bundle ID.

ios/<App>/Info.plist — add:

<key>ITSAppUsesNonExemptEncryption</key>
<false/>

android/app/src/main/AndroidManifest.xml — inside the existing <application> element add:

<meta-data
    android:name="asset_statements"
    android:resource="@string/asset_statements" />

Inside <activity android:name=".MainActivity"> add an additional intent filter for your OAuth deep link (alongside the default LAUNCHER filter, do not replace it):

<intent-filter android:autoVerify="false">
    <action android:name="android.intent.action.VIEW" />
    <category android:name="android.intent.category.DEFAULT" />
    <category android:name="android.intent.category.BROWSABLE" />
    <data android:scheme="myapp" />
</intent-filter>

android/app/src/main/res/values/strings.xml — add:

<string name="asset_statements" translatable="false">
[{ \"include\": \"https://myapp.com/.well-known/assetlinks.json\" }]
</string>

For multiple passkeyDomains, comma-separate the include objects inside the same JSON array (escape every " as \"):

<string name="asset_statements" translatable="false">
[{ \"include\": \"https://myapp.com/.well-known/assetlinks.json\" }, { \"include\": \"https://staging.myapp.com/.well-known/assetlinks.json\" }]
</string>

Android passkeys: Digital Asset Links

Android Credential Manager has no developer-mode bypass like iOS Associated Domains does — every WebAuthn ceremony is validated against https://<rpId>/.well-known/assetlinks.json, which must list your app's package name + signing-cert SHA-256 fingerprint.

1. Publish assetlinks.json at the rpId apex

If passkeyDomains is myapp.com, host at https://myapp.com/.well-known/assetlinks.json. Passkeys need the delegate_permission/common.get_login_creds relation. Add delegate_permission/common.handle_all_urls separately only if the app also uses Android App Links:

[
  {
    "relation": ["delegate_permission/common.get_login_creds"],
    "target": {
      "namespace": "android_app",
      "package_name": "com.example.myapp",
      "sha256_cert_fingerprints": [
        "<debug-keystore-sha-256>",
        "<upload-keystore-sha-256>",
        "<play-app-signing-sha-256>"
      ]
    }
  }
]

The file must be served as Content-Type: application/json over HTTPS with a valid certificate.

2. Get every signing fingerprint you need

cd android && ./gradlew signingReport

Copy the SHA-256 line from each Variant: block. Three flavors to include:

  • Debug: every developer's local builds. If you don't commit a shared debug.keystore, every developer's machine generates a different one — fix is to commit a single keystore to the repo (debug keystores are public-secret per the spec; password is android). See apps/rn-demo/.gitignore for the negation pattern that lets android/app/debug.keystore survive expo prebuild. React Native's template ships a community-default debug.keystore with SHA-256 FA:C6:17:45:DC:09:03:78:6F:B9:ED:E6:2A:96:2B:39:9F:73:48:F0:BB:6F:89:9B:83:32:66:75:91:03:3B:9C — if you copy that file in (it's in react-native-passkey, react-native-fast-image, etc.), every dev's debug build matches a single SHA.
  • Release / upload key: builds you upload to Play Console. Local release builds are signed with this until Google takes over.
  • Play App Signing key: once your first release uploads, Google strips your upload key and re-signs with their managed key. The resulting fingerprint is the one users actually run. Find it in Play Console → Setup → App integrity → App signing key certificate. Add this SHA-256 the moment your first internal-track upload completes — without it, GPM rejects validation on installs shipped through Play.

3. Verify before debugging

Before reinstalling and re-attempting the ceremony, sanity-check the file with Google's official Digital Asset Links validator:

curl 'https://digitalassetlinks.googleapis.com/v1/assetlinks:check?source.web.site=https://myapp.com&relation=delegate_permission/common.get_login_creds&target.android_app.package_name=com.example.myapp&target.android_app.certificate.sha256_fingerprint=<SHA-WITH-COLONS>'

Expected: { "linked": true, ... }. If this returns linked: false, the on-device validator will also fail — fix the file first.

4. Register the Android fingerprint with the moon-x backend

The backend needs to know which Android signing certs are allowed to act as the relying party for your app — Credential Manager substitutes android:apk-key-hash:<base64url(sha256_of_signing_cert)> for the WebAuthn clientDataJSON.origin instead of the HTTPS origin a browser sends, and the platform does an exact-match check.

Set the webauthn.android_apk_key_hashes app setting to a JSON array of every Android signing cert SHA-256 (hex, with or without colons — both forms accepted; same values you put in assetlinks.json):

["FA:C6:17:45:DC:09:...", "AA:BB:CC:..."]

Symptom when missing: passkey ceremony succeeds at the OS level (you see and pass the biometric prompt), then the backend's verify step rejects with error validating origin.

5. Troubleshooting

| Symptom | Likely cause | Fix | |---|---|---| | Native dialog opens, user taps, response never returns; logcat shows [50152] RP ID cannot be validated. | assetlinks.json not reachable, wrong package_name, wrong SHA, OR a stale negative DAL cache from an earlier failure | Verify file with Google's validator (step 3); uninstall and reinstall the app — clearing com.google.android.gms data does NOT evict the per-package DAL cache, only a reinstall does. | | error validating origin from the backend after the OS ceremony succeeds | Android signing fingerprint not registered with the backend | Add the SHA to webauthn.android_apk_key_hashes (step 4). | | Spec-correct setup that worked in dev fails for users installing from Play | Play App Signing key SHA missing | Add the Play App Signing fingerprint from Play Console to assetlinks.json AND webauthn.android_apk_key_hashes. | | androidx.credentials.exceptions.domerrors.DataError@<hash> with no detail | Older react-native-passkeys doesn't surface the underlying DOM error | Watch logcat directly: adb logcat \| grep -E "Auth.Api.Credentials\|Fido" — the real error code (e.g. [50152]) is visible there. | | Persistent failure even though everything looks correct | DAL cache from a prior bad attempt | adb uninstall <package> then reinstall. Do not rely on adb shell pm clear com.google.android.gms — it logs the user out of Google but leaves the per-package DAL cache. |

6. Min versions / known issues

  • Google Password Manager validates more strictly than the WebAuthn spec requires. Some debug-keystore + assetlinks setups that pass Google's centralized DAL validator still fail GPM's on-device check on first install. Reinstall after publishing assetlinks.json is the most reliable workaround.
  • Real device testing is recommended: emulator + GMS sometimes exhibits subtly different validation behaviour.
  • Android 13+ is what we test against. The Credential Manager backport on 9-12 works for most flows but isn't continuously exercised here.

Min OS versions

  • iOS 16+ (passkeys via ASAuthorization)
  • Android 9+ (passkeys via Credential Manager)
  • React Native 0.78+ (React 19 support)
  • Hermes is the supported JS engine. JSC will work for non-passkey flows but isn't tested.

Intentionally unsupported

  • "WithUI" SDK method variants (web-only — RN is headless)
  • React appearance/theming and auth/signing modals
  • RN-mode bundle-ID origin whitelist on the iframe-app (window.ReactNativeWebView-mode currently bypasses origin whitelisting; publishableKey + session JWT are the gates in v1)

Reference

  • Web parity: @moon-x/react-sdk
  • Wire protocol: packages/core/src/utils/post-message.ts
  • Architecture: packages/react-native-sdk/AGENTS.md