Skip to content
LogoLogo

React Native (Expo)

Build passkey-authenticated smart accounts into a native iOS or Android app. The headless Account class from @jaw.id/core runs on React Native: passkey prompts come from the platform's native passkey UI through react-native-passkey, and every signature is produced on the device.

What you can do:

  • Create Accounts - Users create a smart account with Face ID, Touch ID, or the Android biometric prompt
  • Sign & Send - Sign messages and typed data, send single or batched transactions
  • Sponsor Gas - Use any EIP-7677 paymaster, or let users pay gas in USDC
  • Sync Across Devices - Passkeys sync through iCloud Keychain and Google Password Manager

How It Works

In a browser, JAW uses navigator.credentials for WebAuthn. React Native has no navigator.credentials, so you hand JAW two functions from react-native-passkey in AccountConfig:

  • nativeCreateFn (Passkey.create) - called once by Account.create() to register a new passkey
  • nativeGetFn (Passkey.get) - called on login and for every signature afterwards

JAW generates the WebAuthn challenges, converts between base64url and binary, and extracts the public key from the attestation. Passkeys are bound to a domain you control (the rpId), and iOS and Android verify that binding through the app-association files you host in step 3.

Prerequisites

RequirementNotes
Expo development build or React Native CLIExpo Go is not supported - the native modules below require a custom build
React Native 0.76+Minimum for react-native-mmkv v4
iOS 15+ / Android 9+ (API 28)Minimum for react-native-passkey
An HTTPS domain you controlUsed as rpId and to host the iOS and Android app-association files
Apple Team ID and Android signing keystoreReferenced by those association files

Get your API key at dashboard.jaw.id.

1. Install

Install the SDK and viem:

Then install the native modules. Expo does not pin versions for these packages, so check each README for the React Native version it supports (react-native-mmkv v4 needs 0.76+, react-native-quick-crypto 1.x needs 0.75+):

npx expo install react-native-passkey react-native-quick-crypto react-native-quick-base64 react-native-nitro-modules react-native-mmkv
PackagePurpose
react-native-passkeyNative iOS / Android passkey APIs
react-native-quick-cryptoProvides the crypto global that viem and ox expect
react-native-nitro-modulesPeer dependency of react-native-quick-crypto and react-native-mmkv
react-native-quick-base64Peer dependency of react-native-quick-crypto
react-native-mmkvFast synchronous storage for passkey metadata and session state

2. Install the Crypto Polyfill

viem and ox use the Web Crypto API, which Hermes does not ship. Install the polyfill before anything imports @jaw.id/core. ES imports are hoisted, so calling install() next to an import of your app in the same file would run it too late - put it in its own module and import that module first:

// polyfills.js
import { install } from 'react-native-quick-crypto';
 
install(); // sets global.crypto and global.Buffer
// index.js
import './polyfills'; // must stay the first import
import 'expo-router/entry'; // or your app's root component

Point package.json at the entry file:

{
  "main": "index.js"
}

Passkeys are scoped to an rpId. Before the OS lets your app create or use passkeys for that domain, it fetches a file from the domain that names your app. Host both files below on the domain you pass as rpId.

iOS - Associated Domains

Add the domain to app.json:

{
  "expo": {
    "ios": {
      "bundleIdentifier": "com.example.app",
      "associatedDomains": ["webcredentials:example.com"]
    }
  }
}

Serve https://example.com/.well-known/apple-app-site-association over HTTPS with a valid certificate and without redirects:

{
  "webcredentials": {
    "apps": ["TEAMID.com.example.app"]
  }
}

TEAMID is your Apple Developer Team ID.

Set the package name in app.json:

{
  "expo": {
    "android": {
      "package": "com.example.app"
    }
  }
}

Serve https://example.com/.well-known/assetlinks.json:

[
  {
    "relation": ["delegate_permission/common.get_login_creds"],
    "target": {
      "namespace": "android_app",
      "package_name": "com.example.app",
      "sha256_cert_fingerprints": ["AB:CD:EF:..."]
    }
  }
]

The fingerprint must belong to the keystore that signed the build the user is running, so register every keystore you ship with. Local builds use the debug.keystore that expo prebuild generates, EAS builds use the keystore EAS manages, and Play Store builds are re-signed by Play App Signing:

# Local builds (android/app/debug.keystore, generated by expo prebuild)
keytool -list -v -keystore android/app/debug.keystore -alias androiddebugkey -storepass android -keypass android
 
# EAS builds: prints the managed keystore's fingerprints
eas credentials -p android

For Play Store releases, take the SHA-256 from Play Console under Release → Setup → App integrity → App signing key certificate.

Build

npx expo prebuild --clean
npx expo run:ios
npx expo run:android

Rerun prebuild --clean whenever you change native dependencies or the ios / android sections of app.json. JavaScript-only changes don't need a rebuild; if Metro serves stale code, restart it with npx expo start --clear.

4. Persist Storage

JAW keeps passkey metadata (credential IDs, public keys, addresses) and the current session in a SyncStorage. On the web that is localStorage. React Native has no default: without storage, JAW falls back to an in-memory map that is wiped on every app restart, and users would have to import their passkey again. Back it with MMKV:

// lib/storage.ts
import { createMMKV } from 'react-native-mmkv';
import type { SyncStorage } from '@jaw.id/core';
 
const mmkv = createMMKV({ id: 'jaw' });
 
export const storage: SyncStorage = {
  getItem: <T>(key: string): T | null => {
    const value = mmkv.getString(key);
    if (value === undefined) return null;
    try {
      return JSON.parse(value) as T;
    } catch {
      return value as T;
    }
  },
  setItem: (key, value) => {
    mmkv.set(key, typeof value === 'string' ? value : JSON.stringify(value));
  },
  removeItem: (key) => {
    mmkv.remove(key);
  },
};

5. Configure the Account

Build one AccountConfig and reuse it for every call. The React Native options live in the config next to chainId and apiKey:

// lib/jaw.ts
import { Passkey } from 'react-native-passkey';
import type { AccountConfig, NativePasskeyCreateFn, NativePasskeyGetFn } from '@jaw.id/core';
import { storage } from './storage';
 
export const config: AccountConfig = {
  chainId: 8453, // Base
  apiKey: process.env.EXPO_PUBLIC_JAW_API_KEY!,
  storage,
  rpId: 'example.com', // the domain you linked in step 3
  rpName: 'My App', // relying-party name for the passkey ceremony
  nativeGetFn: Passkey.get as NativePasskeyGetFn,
  nativeCreateFn: Passkey.create as NativePasskeyCreateFn,
};
OptionTypeRequiredDescription
storageSyncStorageNoPersistent storage. Without it JAW uses an in-memory store that resets on every restart
nativeGetFnNativePasskeyGetFnYesPasskey.get - used for login and for every signature
nativeCreateFnNativePasskeyCreateFnFor Account.create()Passkey.create - used to register a new passkey
rpIdstringYesDomain the passkeys are bound to. Must match the association files from step 3
rpNamestringNoRelying-party name passed to the passkey ceremony. Defaults to JAW

6. Create an Account

import { Account } from '@jaw.id/core';
import { config } from './lib/jaw';
 
const account = await Account.create(config, { username: 'alice' });
console.log(account.address);

Account.create() opens the native passkey prompt, derives the smart account address, registers the passkey metadata with JAW, and saves the account and session to your storage. The smart account itself is deployed with its first transaction.

You don't need to persist anything yourself. Account.getStoredAccounts(config.apiKey, config.storage) lists every account created or imported on the device.

7. Sign In

// No passkey prompt - restores the last session from storage (throws if there is none)
const account = await Account.get(config);

Account.import() is how a user signs in on a new device. It shows the passkeys synced to the device for your rpId and looks the selected one up in JAW's passkey registry, so the passkey must have been registered through JAW - by another install of your app, or by your web app running in AppSpecific mode on the exact hostname you use as rpId (the web app binds passkeys to its own hostname). Because the smart account address is derived from the passkey's public key, the user lands in the same account.

Check the sign-in state and log out:

const address = Account.getAuthenticatedAddress(config.apiKey, config.storage); // null when signed out
 
Account.logout(config.apiKey, config.storage);

8. Sign and Send

Every signature triggers the native passkey prompt through nativeGetFn.

const signature = await account.signMessage('Hello from mobile');
import { parseEther } from 'viem';
 
// Waits for the receipt and returns the transaction hash
const hash = await account.sendTransaction([{ to: '0xRecipient...', value: parseEther('0.01') }]);
// Submit a batch and poll for its status
const { id } = await account.sendCalls([
  { to: '0xContractA...', data: '0x...' },
  { to: '0xContractB...', data: '0x...' },
]);
 
const status = await account.getCallStatus(id);
// status.status: 100 pending, 200 completed, 400 offchain failure, 500 onchain revert

9. Pay for Gas

A new smart account is deployed with its first transaction. Sponsor gas or let users pay in USDC so they never need to hold ETH.

Set paymasterUrl in AccountConfig to sponsor every transaction with an EIP-7677 paymaster:

export const config: AccountConfig = {
  // ...
  paymasterUrl: 'https://api.pimlico.io/v2/8453/rpc?apikey=YOUR_PIMLICO_KEY',
};

To pay gas in USDC instead, pass JAW's ERC-20 paymaster per call as shown in Embed Stablecoin Payments.

Troubleshooting

SymptomCauseFix
Property 'crypto' doesn't existPolyfill ran after @jaw.id/core loadedKeep import './polyfills' as the first import of index.js and set "main": "index.js"
Nitro module errors on launchRunning in Expo GoBuild a development client with npx expo run:ios or npx expo run:android
rpId is required in non-browser environmentsrpId missing from AccountConfigSet rpId to the domain you linked in step 3
iOS: AuthorizationError Code=1004, or the prompt closes without a credentialAASA not reachable or missing your Team ID + bundle identifiercurl https://<rpId>/.well-known/apple-app-site-association, confirm TEAMID.bundleId is listed, rerun prebuild --clean, reinstall the app
Android: Credential Manager error that does not mention asset linksassetlinks.json missing or wrong fingerprintcurl https://<rpId>/.well-known/assetlinks.json and confirm the fingerprint of the keystore that signed the build
Accounts disappear after restartNo storage in AccountConfigPass the MMKV-backed SyncStorage from step 4
Passkey not yet visible in Google Password Manager after creationCloud sync lagWait a moment - sign-in already works on the device
AA21 didn't pay prefund on the first transactionThe account holds no ETH and no paymaster is configuredConfigure a paymaster (step 9) or fund the address
AA13 initCode failed or OOGThe account deployment (initCode) reverted or ran out of gasUpdate @jaw.id/core to the latest version - older releases produced a 65-byte public key the factory rejected