Install

pnpm add @turnkey/react-wallet-kit
# or
npm i @turnkey/react-wallet-kit

New features and improvements

  • Unified provider + modal flows: Built-in UI for Passkey, Wallet, Email/SMS OTP, and OAuth (Google, Apple, Facebook, X, Discord).
  • Automatic OAuth redirect handling: The provider completes OAuth redirects on page load; no manual token injection.
  • First-class export/import: Secure export/import via Turnkey iframes with simple handlers.
  • External wallet connection: Connect MetaMask, Phantom, WalletConnect providers for Ethereum/Solana.
  • Session lifecycle management: Auto-refresh with beforeSessionExpiry/onSessionExpired callbacks.
  • UI customization: Theme overrides, dark mode, border radius/background blur, and modal placement.

Breaking changes

Provider and hooks

Old Approach (Manual SDK) Previously, @turnkey/sdk-react exposed a TurnkeyProvider and a custom TurnkeyContext; you manually managed multiple clients (passkeyClient, iframeClient, walletClient, indexedDbClient).
import { TurnkeyProvider } from "@turnkey/sdk-react";
New Approach (Hook-Based: useTurnkey()) Use @turnkey/react-wallet-kit’s TurnkeyProvider and useTurnkey hook, which returns state (session, user, wallets, etc.), high-level helpers (auth, export/import, signing, external wallets), and all TurnkeyClientMethods from @turnkey/core.
import { TurnkeyProvider } from "@turnkey/react-wallet-kit";
Key differences:
  • Single provider + hook replaces multiple manually managed clients
  • High-level helpers for auth/export/import/signing/external wallets
  • All core client methods available via the hook

Configuration

Old Approach (Manual SDK iframe config) Previously, auth flows used iframe-specific configuration (e.g., iframeUrl) and manual wiring for auth flows. OAuth often required redirect handling and token injection. New Approach (Hook-Based Provider config) Use the provider config with auth, ui, wallet configuration, and optional export/import iframe URLs. Built-in modal flows replace iframe-specific auth settings.
<TurnkeyProvider
  config={{
    // Core config (same fields supported)
    apiBaseUrl: "...",
    organizationId: "...",
    authProxyUrl: "...",
    authProxyConfigId: "...",
    passkeyConfig: { rpId: "...", timeout: 60000 },

    // Optional iframe URLs for export/import
    exportIframeUrl: "https://export.turnkey.com",
    importIframeUrl: "https://import.turnkey.com",

    // Auth controls
    auth: {
      methods: {
        passkeyAuthEnabled: true,
        walletAuthEnabled: true,
        googleOauthEnabled: true,
      },
      oauthConfig: {
        oauthRedirectUri: "https://your.app/callback",
        googleClientId: "GOOGLE_CLIENT_ID",
        openOauthInPage: false,
      },
      autoRefreshSession: true,
    },

    // Wallets and chains
    walletConfig: {
      features: { auth: true, connecting: true },
      chains: {
        ethereum: { native: true },
        solana: { native: true },
      },
      // walletConnect: { projectId: "..." },
    },

    // UI customization
    ui: { darkMode: true, renderModalInProvider: true },
  }}
>
  {children}
</TurnkeyProvider>
Key differences:
  • Replace iframe-specific auth settings with auth.* and built-in modal flows
  • Centralize OAuth settings via auth.oauthConfig; redirect handling is automatic
  • Configure wallets/chains and UI theming directly on the provider

Authentication

  • Manual client selection/injection has been replaced by a single modal-driven flow (handleLogin) and dedicated OAuth helpers. Stop calling injectCredentialBundle on iframes; the provider manages sessions and completes OAuth automatically.

OAuth

Old Approach (Manual SDK) Previously, you manually parsed code/state from the URL, exchanged for a token, and injected credentials into an iframe. New Approach (Hook-Based: useTurnkey()) Use a dedicated helper to trigger the OAuth flow. The provider completes redirects automatically on load.
const { handleGoogleOauth } = useTurnkey();
await handleGoogleOauth({ openInPage: false }); // set true for redirect
Key differences:
  • No manual URL parsing/token exchange/injection
  • Single helper per provider; popup or full-page redirect
  • Redirect completion handled automatically by the provider
The same applies for other OAuth providers such as Apple, Facebook. See Configuring OAuth for more details.

Passkeys

Old Approach (Manual SDK) Previously, passkey login used a read/write session with a freshly generated public key. Passkey signup first created a WebAuthn credential (challenge + attestation) and then created a new sub-organization/user using that credential.
import { useTurnkey } from "@turnkey/sdk-react";
import { SessionType } from "@turnkey/sdk-types";

const { passkeyClient, indexedDbClient } = useTurnkey();

await indexedDbClient?.resetKeyPair();
const publicKey = await indexedDbClient!.getPublicKey();

await passkeyClient?.loginWithPasskey({
  sessionType: SessionType.READ_WRITE,
  publicKey,
});
New Approach (Hook-Based: useTurnkey()) Use the built-in helpers to trigger passkey login and sign-up flows.
import { useTurnkey } from "@turnkey/react-wallet-kit";

const { handlePasskeyLogin, handlePasskeySignUp } = useTurnkey();
await handlePasskeyLogin();
await handlePasskeySignUp();
Key differences:
  • No manual public key management or session type selection
  • WebAuthn challenge/attestation handled by helpers and proxy
  • Provider manages session creation and storage

OTP (SMS & Email)

Old Approach (Manual SDK + Server Actions) Previously, OTP auth required:
  • A server action to initialize OTP (and optionally create a sub-org if none exists) using the parent org API key.
  • A server action to verify OTP and exchange the verification token for a session (read/write), stamped by the server key.
  • Client code to fetch the device public key and then call those server actions.
import { useTurnkey } from "@turnkey/sdk-react";
import { initEmailAuth, otpLogin } from "./actions";

const { indexedDbClient } = useTurnkey();
await indexedDbClient?.resetKeyPair();
const publicKey = await indexedDbClient!.getPublicKey();

// Start OTP on server
const init = await initEmailAuth({ email, targetPublicKey: publicKey });
// Verify on server and exchange for session
const session = await otpLogin({
  email,
  publicKey,
  otpId: init.otpId,
  otpCode: code,
});

await indexedDbClient?.loginWithSession(session);
New Approach (Hook-Based: useTurnkey()) The provider handles proxy calls; you no longer need to stamp server-side with the parent org key, nor manually exchange verification tokens. Client code initializes OTP and completes it with completeOtp. Initialize (client):
import { useTurnkey } from "@turnkey/react-wallet-kit";

const { initOtp } = useTurnkey();

const init = await initOtp({
  otpType: "OTP_TYPE_EMAIL",
  contact: email,
});
// navigate to verification page with init.otpId
Complete (client):
import { useTurnkey } from "@turnkey/react-wallet-kit";
import { OtpType } from "@turnkey/sdk-types";

const { completeOtp } = useTurnkey();

await completeOtp({
  otpId,
  otpCode: code,
  contact: email,
  otpType: OtpType.Email,
  // optional: create sub-org on first login
  createSubOrgParams: {
    customWallet,
    userEmail: email,
  },
});
Key differences:
  • Server-stamped flows (parent org API key) are no longer required for standard OTP; the provider’s proxy methods handle the secure exchange.
  • You call proxyInitOtp and completeOtp directly from the client; the SDK manages session creation and storage.
  • Optional sub-org creation can be passed via createSubOrgParams during completion.

Wallet Authentication (Ethereum/Solana)

Old Approach (Manual SDK + Server Actions) Previously, wallet authentication required:
  • Configuring Turnkey with a Wallet interface (e.g., EthereumWallet) and wrapping your app with the provider.
  • Deriving a public key from the user’s external wallet (for Ethereum this involves a signMessage prompt).
  • Optionally creating a sub-organization (sign-up) on the server using the parent org API key pair.
  • Creating a read/write session via loginWithWallet, bound to a browser-managed IndexedDB API key.
import { EthereumWallet } from "@turnkey/wallet-stamper";

export const turnkeyConfig = {
  apiBaseUrl: "https://api.turnkey.com",
  defaultOrganizationId: process.env.NEXT_PUBLIC_ORGANIZATION_ID!,
  wallet: new EthereumWallet(),
};
New Approach (Hook-Based: useTurnkey()) Use the hook-based helpers to trigger wallet authentication flows. The provider abstracts provider discovery, public key derivation, and session creation/storage. A single call will prompt the external wallet for any required signatures and establish a Turnkey session. Key differences:
  • No manual walletClient.getPublicKey() or message signing to derive a public key
  • No SessionType or manual IndexedDB session management; the provider manages session lifecycle
  • One-liners for “Continue with Wallet” (auto sign in or sign up), or explicit Sign Up / Sign In
  • Works for Ethereum and Solana; pass Chain.Ethereum or Chain.Solana to getWalletProviders and choose a provider
"use client";
import { useTurnkey, Chain } from "@turnkey/react-wallet-kit";

export default function ContinueWithWallet() {
  const { getWalletProviders, loginOrSignupWithWallet } = useTurnkey();

  const handleContinue = async () => {
    const providers = await getWalletProviders(Chain.Solana); // or Chain.Ethereum
    const provider = providers[0]; // pick the desired provider
    await loginOrSignupWithWallet({ walletProvider: provider });
  };

  return <button onClick={handleContinue}>Continue with Wallet</button>;
}

Passkeys

Add passkey

Before:
import { useTurnkey } from "@turnkey/sdk-react";

const { passkeyClient, indexedDbClient, turnkey } = useTurnkey();
const user = await turnkey?.getCurrentUser();
const credential = await passkeyClient?.createUserPasskey({
  publicKey: {
    rp: { name: "Wallet Passkey" },
    user: { name: user.username, displayName: user.username },
  },
});
await indexedDbClient.createAuthenticators({
  userId: user.userId,
  authenticators: [
    {
      authenticatorName: "New Passkey",
      challenge: credential.encodedChallenge,
      attestation: credential.attestation,
    },
  ],
});
After:
import { useTurnkey } from "@turnkey/react-wallet-kit";

const { handleAddPasskey, user } = useTurnkey();
await handleAddPasskey({
  userId: user?.userId,
  displayName: "My device",
});

Remove passkey

Before:
import { useTurnkey } from "@turnkey/sdk-react";

const { indexedDbClient, turnkey } = useTurnkey();

const user = await turnkey?.getCurrentUser();

const resp = await indexedDbClient.getAuthenticators({
  organizationId: user?.organizationId,
  userId: user?.userId,
});
const authenticatorId = resp?.authenticators?.[0]?.authenticatorId;

await indexedDbClient.deleteAuthenticators({
  userId: user?.userId,
  authenticatorIds: [authenticatorId],
});
After:
import { useTurnkey } from "@turnkey/react-wallet-kit";

const { user, httpClient, handleRemovePasskey } = useTurnkey();

const resp = await httpClient?.getAuthenticators({ userId: user?.userId });
const authenticatorId = resp?.authenticators?.[0]?.authenticatorId;

if (authenticatorId) {
  await handleRemovePasskey({
    authenticatorId,
    userId: user?.userId,
  });
}

Wallets

List wallets

Old Approach (Manual SDK Calls) Previously, wallet listing relied on manually instantiating a Turnkey client and invoking SDK methods to fetch both wallets and their accounts.
const { wallets } = await indexedDbClient.getWallets();
const walletsWithAccounts = await Promise.all(
  wallets.map(async (wallet) => {
    const { accounts } = await indexedDbClient.getWalletAccounts({
      walletId: wallet.walletId,
    });
    // ...merge accounts
    return { ...wallet, accounts };
  })
);
New Approach (Hook-Based: useTurnkey()) The new method utilizes the useTurnkey React hook, which abstracts data fetching, session management, and provides ready-to-use wallet/account lists and actions.
const {
  wallets: hookWallets,
  createWallet,
  createWalletAccounts,
  user,
  session,
} = useTurnkey();

// Listing wallets is now as simple as using hookWallets
const wallets = hookWallets ?? [];
Key differences:
  • No manual wallet/account fetch + merge
  • Hook provides wallets and accounts with provider-managed session
  • Simpler state consumption via useTurnkey()

Creating Wallets and Accounts

Old Approach (Manual SDK Calls) Previously, creating wallets and accounts involved calling SDK methods directly (e.g., createWallet, createWalletAccounts) and then refetching wallets to reflect changes. Additionally, when creating a new Ethereum account you would often compute the next default account at a specific index via a helper (e.g., defaultEthereumAccountAtIndex(index)) and pass that into createWalletAccounts.
import { useTurnkey } from "@turnkey/sdk-react";

const { indexedDbClient } = useTurnkey();

// Create a new wallet with one Ethereum account
const { walletId } = await indexedDbClient.createWallet({
  walletName: "Demo Wallet",
  accounts: ["ADDRESS_FORMAT_ETHEREUM"],
});

// Compute a default Ethereum account at the next index to maintain derivation path ordering
const newAccount = defaultEthereumAccountAtIndex(
  state.selectedWallet.accounts.length + 1
);

// Add a new Ethereum account to the created wallet
const newAccountAddress = await indexedDbClient.createWalletAccounts({
  walletId,
  accounts: [newAccount],
});

// Refresh/read wallets to update local state
const { wallets } = await indexedDbClient.getWallets();
New Approach (Hook-Based: useTurnkey()) Use useTurnkey() helpers createWallet and createWalletAccounts. The provider manages session and state and refreshing wallets; you only invoke the actions.
import React from "react";
import { useTurnkey } from "@turnkey/react-wallet-kit";

export function WalletActions() {
  const { createWallet, createWalletAccounts, wallets } = useTurnkey();

  const handleCreateWallet = async () => {
    const walletId = await createWallet({
      walletName: "Demo Wallet",
      accounts: ["ADDRESS_FORMAT_ETHEREUM"],
    });

    console.log("Created wallet:", walletId);
  };

  const handleCreateAccount = async () => {
    if (!wallets?.length) return;
    const walletId = wallets[0].walletId;
    const newAccountAddress = await createWalletAccounts({
      walletId,
      accounts: ["ADDRESS_FORMAT_ETHEREUM"],
    });

    console.log("Created account:", newAccountAddress);
  };

  return (
    <div>
      <button onClick={handleCreateWallet}>Create Wallet</button>
      <button onClick={handleCreateAccount}>Create Wallet Account</button>
    </div>
  );
}

Export

Old Approach (Manual SDK + iframe) Previously, exporting required manually initializing an export iframe and orchestrating export API calls and decryption within the iframe.
import { useTurnkey } from "@turnkey/sdk-react";

const { turnkey, indexedDbClient } = useTurnkey();

// 1) Create export iframe client
const iframeClient = await turnkey?.iframeClient({
  iframeContainer: document.getElementById(
    "turnkey-export-iframe-container-id"
  )!,
  iframeUrl: "https://export.turnkey.com",
});

// 2a) Export a wallet (seed phrase)
const walletExport = await indexedDbClient?.exportWallet({
  walletId: selectedWallet.walletId,
  targetPublicKey: iframeClient?.iframePublicKey,
});
const session = await turnkey?.getSession();
await iframeClient?.injectWalletExportBundle(
  walletExport!.exportBundle,
  session?.organizationId
);

// 2b) Export a private key (by account address)
const keyExport = await indexedDbClient?.exportWalletAccount({
  address: selectedAccount.address,
  targetPublicKey: iframeClient?.iframePublicKey,
});
await iframeClient?.injectKeyExportBundle(
  keyExport!.exportBundle,
  session?.organizationId
);
New Approach (Hook-Based: useTurnkey()) Use the built-in export handlers that open a modal and perform the export/iframe flow for you.
const {
  handleExportWallet,
  handleExportPrivateKey,
  handleExportWalletAccount,
} = useTurnkey();

await handleExportWallet({ walletId: "..." });
// await handleExportPrivateKey({ privateKeyId: "...", keyFormat: KeyFormat.Hexadecimal });
// await handleExportWalletAccount({ address: "0x...", keyFormat: KeyFormat.Hexadecimal });
Key differences:
  • Modal handlers replace manual iframe client orchestration
  • No direct bundle injection/extraction steps
  • Provider/session context handled automatically

Import

Old Approach (Manual SDK + iframe)
import { useTurnkey } from "@turnkey/sdk-react";

const { turnkey, indexedDbClient } = useTurnkey();

// 1) Create import iframe client
const iframeClient = await turnkey?.iframeClient({
  iframeContainer: document.getElementById(
    "turnkey-import-iframe-container-id"
  )!,
  iframeUrl: "https://import.turnkey.com",
});

// 2) Initialize import (wallet or private key) and inject bundle into iframe
const session = await turnkey?.getSession();

// Wallet
const initWallet = await indexedDbClient?.initImportWallet({
  userId: session?.userId,
});
await iframeClient?.injectImportBundle(
  initWallet!.importBundle,
  session?.organizationId,
  session?.userId
);

// or Private Key
const initKey = await indexedDbClient?.initImportPrivateKey({
  userId: session?.userId,
});
await iframeClient?.injectImportBundle(
  initKey!.importBundle,
  session?.organizationId,
  session?.userId
);

// 3) Extract encrypted bundle from iframe and submit import
// Wallet
const encryptedWalletBundle =
  await iframeClient?.extractWalletEncryptedBundle();
await indexedDbClient?.importWallet({
  userId: session?.userId,
  walletName: "Imported Wallet",
  encryptedBundle: encryptedWalletBundle!,
  accounts: ["ADDRESS_FORMAT_ETHEREUM"],
});

// Private Key
const encryptedKeyBundle = await iframeClient?.extractKeyEncryptedBundle();
await indexedDbClient?.importPrivateKey({
  userId: session?.userId,
  privateKeyName: "Imported Key",
  encryptedBundle: encryptedKeyBundle!,
  curve: "CURVE_SECP256K1",
  addressFormats: ["ADDRESS_FORMAT_ETHEREUM"],
});
New Approach (Hook-Based: useTurnkey())
import { useTurnkey } from "@turnkey/react-wallet-kit";

const { handleImportWallet, handleImportPrivateKey } = useTurnkey();

// Import a wallet (optionally pre-fill default accounts)
await handleImportWallet({
  // defaultWalletAccounts: ["ADDRESS_FORMAT_ETHEREUM"],
  // successPageDuration: 2000,
});

// Import a private key (must pass curve and address formats)
await handleImportPrivateKey({
  curve: "CURVE_SECP256K1",
  addressFormats: ["ADDRESS_FORMAT_ETHEREUM"],
  // successPageDuration: 2000,
});

Signing

Transactions

Old Approach Previously, transactions were signed using ethers with TurnkeySigner from @turnkey/ethers, wired to the @turnkey/sdk-react client. You manually constructed the signer/provider and invoked sendTransaction.
import { ethers } from "ethers";
import { TurnkeySigner } from "@turnkey/ethers";
import { useTurnkey } from "@turnkey/sdk-react";

const { turnkey, indexedDbClient } = useTurnkey();

const provider = new ethers.JsonRpcProvider(<rpcUrl>);
const currentUser = await turnkey.getCurrentUser();
const signer = new TurnkeySigner({
  client: indexedDbClient,
  organizationId: currentUser.organization.organizationId,
  signWith: "0xYourWalletAddress",
}).connect(provider);

const tx = {
  to: "0x0000000000000000000000000000000000000000",
  value: ethers.parseEther("0.001"),
  type: 2,
};

await signer.sendTransaction(tx);
New Approach Use useTurnkey()’s signing helpers:
  • signTransaction: signs and returns a signature (you broadcast separately).
  • signAndSendTransaction: signs and broadcasts, returning the on-chain transaction hash.
The provider handles session state and request stamping; you pass a wallet account and an unsigned transaction. For more details, see: Signing transactions.
import { useTurnkey } from "@turnkey/react-wallet-kit";
import { parseEther, Transaction } from "ethers";

function SignTransactionButton() {
  const { signAndSendTransaction, signTransaction, wallets } = useTurnkey();

  const doSignTransaction = async () => {
    const walletAccount = wallets[0]?.accounts[0];
    if (!walletAccount) return;

    const tx = {
      to: "0x0000000000000000000000000000000000000000",
      value: parseEther("0.001"),
      nonce: 0,
      gasLimit: BigInt(21000),
      maxFeePerGas: BigInt(1e9),
      maxPriorityFeePerGas: BigInt(1e9),
      chainId: 1,
    };

    const unsignedTransaction = Transaction.from(tx).unsignedSerialized;

    // sign the transaction
    const signature = await signTransaction({
      walletAccount,
      unsignedTransaction,
      transactionType: "TRANSACTION_TYPE_ETHEREUM",
    });

    console.log("Transaction signed:", signature);

    // OR sign and send the transaction (requires rpcUrl for embedded wallets)
    const hash = await signAndSendTransaction({
      walletAccount,
      unsignedTransaction,
      transactionType: "TRANSACTION_TYPE_ETHEREUM",
      rpcUrl: "https://mainnet.infura.io/v3/YOUR_KEY",
    });
  };

  return <button onClick={doSignTransaction}>Sign Transaction</button>;
}
Note: rpcUrl is required when using embedded wallets (to broadcast via your chosen RPC). For external wallets (e.g., MetaMask, Phantom), rpcUrl is not required and will be ignored. Key differences:
  • No TurnkeySigner or manual provider wiring
  • Pass an unsigned transaction and account; SDK stamps and sends
  • Session/state handled by the provider

Messages

Old Approach Messages were signed via ethers using TurnkeySigner from @turnkey/ethers, with manual signer/provider wiring against the @turnkey/sdk-react client.
import { useTurnkey } from "@turnkey/sdk-react";
import { TurnkeySigner } from "@turnkey/ethers";
import { ethers } from "ethers";

const { turnkey, indexedDbClient } = useTurnkey();

const provider = new ethers.JsonRpcProvider(<rpcUrl>);
const currentUser = await turnkey.getCurrentUser();
const signer = new TurnkeySigner({
  client: indexedDbClient,
  organizationId: currentUser.organization.organizationId,
  signWith: "0xYourWalletAddress",
}).connect(provider);

const signature = await signer.signMessage("Hello Turnkey");
New Approach Use useTurnkey()’s signMessage to sign directly with a selected wallet account. For a modal-driven UX, handleSignMessage opens a confirmation dialog.
import { useTurnkey } from "@turnkey/react-wallet-kit";

function SignMessageButton() {
  const { signMessage, wallets } = useTurnkey();

  const doSignMessage = async () => {
    const walletAccount = wallets[0]?.accounts[0];
    if (!walletAccount) return;

    const signature = await signMessage({
      walletAccount,
      message: "Hello, Turnkey!",
    });
    console.log("Message signed:", signature);
  };

  return <button onClick={doSignMessage}>Sign Message</button>;
}
See Signing messages for more details: Signing messages.