Skip to content
Alchemy Logo

Migrate JWT authentication from Alchemy to Privy

This guide is for developers who authenticate users with Alchemy Signer using JWT (JSON Web Token) authentication and need to migrate to Privy.

If you use standard auth methods (email, Google, passkeys) with the React SDK, use the React migration guide instead.
Before starting, read the signer migration overview to confirm your account type (Modular Account v2 vs. another implementation) and connection type (EIP-7702 vs. ERC-4337). Apps not on MAv2, or using pure 4337, need to adjust a step in this guide — the overview's Edge cases section walks through each.

With JWT auth, you control both sides of authentication — your server generates JWTs, and both Alchemy and your new provider accept them. This simplifies the migration because:

  • No user export/import needed (unless you also use other auth methods) — both providers authenticate via your JWT
  • JWTs are reusable — they're not one-time-use tokens, so the same JWT can authenticate with both Alchemy and Privy in a single session
  • Migration detection is your responsibility — since there's no Privy-side metadata, you track which users have migrated in your own database

This guide covers two paths. Both share the same Privy dashboard setup (Steps 1–2a), but differ in how authentication and wallet import happen:

Client-side pathServer-side path
Best forReact / React Native apps where the user's browser handles authBackend services where your server controls auth and wallets
Auth hookuseSubscribeToJwtAuthWithFlag (React SDK)REST API: exchange JWT for a Privy userSigner
Wallet importuseImportWallet hook (client-side)@privy-io/node SDK or REST API /v1/wallets/import/*
Private key exposureBriefly in client memory during export→importOn your server during export→import

Add a field to your user/account model to track migration status. Every existing user starts as needs_migration = true.

ALTER TABLE users ADD COLUMN signer_migrated BOOLEAN DEFAULT FALSE;

Or use whatever mechanism fits your stack — a boolean field, a status enum, a separate migration table. The point is: you need a way to check on each login whether this user still needs their Alchemy wallet keys migrated.

This setup is the same regardless of whether you choose the client-side or server-side path.

  1. Go to the Privy Dashboard and create an app
  2. Request access to Custom Auth Support in the Integrations > Plugins tab
  3. Navigate to User Management > Authentication > JWT-based auth
  4. Select the environment:
    • Client side if JWT-authenticated requests will come from end-user devices (React / React Native)
    • Server side if requests will come from your backend servers
  5. Provide your JWT verification key — either a JWKS endpoint URL or a public verification key (PEM certificate)
  6. Enter the JWT claim that contains the user's unique ID (usually sub)

See the Privy JWT setup docs for full details.

While existing users still need migrating, you don't want Privy to auto-create a fresh wallet before you import their Alchemy wallet. If using a client SDK, set createOnLogin: "off":

<PrivyProvider
  appId="YOUR_PRIVY_APP_ID"
  config={{
    embeddedWallets: {
      createOnLogin: "off",
    },
  }}
>

For new users (signing up after your migration is live): because you track migration status in your own database (Step 1), new users start with signer_migrated = true (no Alchemy wallet to migrate). Create a Privy wallet for them explicitly after login — either by calling createWallet from the client SDK or via the @privy-io/node SDK server-side. Once all existing users have migrated, switch createOnLogin back to "users-without-wallets" and you can drop the explicit creation call.

Install @alchemy/wallet-apis and wire up createSmartWalletClient. See the Privy signer integration guide for full setup details, or follow the React migration guide Step 2 for a walkthrough.


Use this path if you have a React or React Native app and want the migration to happen in the user's browser/device.

Instead of calling a Privy login function directly, you subscribe the Privy SDK to your existing auth provider's state using the useSubscribeToJwtAuthWithFlag hook. Privy will automatically authenticate when your provider reports the user is logged in.

In a component that lives below both PrivyProvider and your auth provider:

import {useAuth} from 'your-auth-provider';
import {useSubscribeToJwtAuthWithFlag} from '@privy-io/react-auth';
 
const AuthStateSync = () => {
  const {getToken, isLoading, isAuthenticated} = useAuth();
 
  useSubscribeToJwtAuthWithFlag({
    isAuthenticated,
    isLoading,
    getExternalJwt: async () => {
      if (isAuthenticated) {
        return await getToken();
      }
    },
  });
 
  return null;
};

Mount this component throughout the lifetime of your app to keep Privy in sync:

import {AuthProvider} from 'your-auth-provider';
import {PrivyProvider} from '@privy-io/react-auth';
 
function App() {
  return (
    <AuthProvider>
      <PrivyProvider appId="YOUR_PRIVY_APP_ID" config={{embeddedWallets: {createOnLogin: 'off'}}}>
        <AuthStateSync />
        <MainContent />
      </PrivyProvider>
    </AuthProvider>
  );
}

You can check the user's Privy auth status with usePrivy:

import {usePrivy} from '@privy-io/react-auth';
 
function MainContent() {
  const {user, ready, authenticated} = usePrivy();
 
  if (!ready) return <div>Loading...</div>;
  if (!authenticated) return <div>Please log in through your authentication provider</div>;
 
  return <div>Welcome, {user.id}!</div>;
}
Do not call Privy's login method (from useLogin or usePrivy) when using JWT-based auth. Let your auth provider handle login; Privy syncs automatically.

See the Privy JWT usage docs for full details.

The migration happens in a single session where the user is authenticated with both Alchemy and Privy simultaneously.

// useImportWallet is a React hook — call it at the component/hook level, not inside async functions
const {importWallet} = useImportWallet();
 
async function handleMigration(userId: string) {
  // 1. Get JWT from your auth provider (same token Privy is already using)
  const jwt = await getToken();
 
  // 2. Check if user needs migration
  const user = await getUserFromDB(userId);
  if (!user.signer_migrated) {
    // 3. Authenticate with Alchemy using the SAME JWT
    const alchemySigner = new AlchemyWebSigner({
      client: {
        connection: {apiKey: 'YOUR_ALCHEMY_API_KEY'},
        iframeConfig: {iframeContainerId: 'alchemy-signer-iframe-container'},
      },
    });
    await alchemySigner.authenticate({type: 'jwt', token: jwt});
 
    // 4. Export private key from Alchemy
    const privateKey = await alchemySigner.exportPrivateKey();
 
    // 5. Import into Privy
    await importWallet({privateKey});
 
    // 6. Mark migration complete
    await updateUserDB(userId, {signer_migrated: true});
  }
}

The snippet in 4b already authenticates with Alchemy using the same JWT and calls exportPrivateKey(). A note on when to use the encrypted variant:

// Plaintext export — fine for the fully client-side flow above
const privateKey = await alchemySigner.exportPrivateKey();
 
// If you need to ship the key to another process (e.g. your backend for the server-side path),
// export it encrypted against a public key you control:
const privateKeyEncrypted = await alchemySigner.exportPrivateKeyEncrypted(...);

Use the useImportWallet hook from @privy-io/react-auth. It accepts a raw hex private key (with or without 0x prefix) for Ethereum, or a base58-encoded key for Solana.

Ethereum:

import {useImportWallet} from '@privy-io/react-auth';
 
const {importWallet} = useImportWallet();
const wallet = await importWallet({privateKey: ethPrivateKey});

Solana:

import {useImportWallet} from '@privy-io/react-auth/solana';
 
const {importWallet} = useImportWallet();
const wallet = await importWallet({privateKey: solPrivateKey});

Update your database so the user isn't prompted again on next login.

await db.users.update(userId, {signer_migrated: true});

Alchemy's JWT signer runs in the browser via AlchemyWebSigner — there is no supported path for authenticating an Alchemy JWT signer from a backend. That means the export from Alchemy must still happen client-side (as in 4a–4c above). This server-side path only covers the import side: your client exports the key from Alchemy, ships it to your server, and your server imports it into Privy using the @privy-io/node SDK.

For most apps, the fully client-side path in 4a–4e is simpler. Use this path only if you have a specific reason to move the import to the server — for example, to attach policies, owners, or additional signers at import time.

In the Privy dashboard JWT configuration (Step 2a), select "Server side" so Privy will accept JWTs from your backend. Then install the SDK:

import {PrivyClient} from '@privy-io/node';
 
const privy = new PrivyClient({
  appId: 'your-privy-app-id',
  appSecret: 'your-privy-app-secret',
});

On the client, export the private key from Alchemy as in 4c. To avoid sending plaintext key material over the network, use exportPrivateKeyEncrypted with a public key owned by your backend:

// Client
const encryptedKey = await alchemySigner.exportPrivateKeyEncrypted({
  publicKey: serverPublicKey,
});
await fetch('/api/migrate-signer', {
  method: 'POST',
  body: JSON.stringify({ encryptedKey }),
});

Decrypt the key with your server's private key, then pass it to the Node SDK. @privy-io/node re-encrypts the key with HPKE before sending it to Privy's TEE, so the plaintext key never leaves your server over the wire.

async function handleMigrateSigner(userId: string, encryptedKey: string) {
  const user = await getUserFromDB(userId);
  if (user.signer_migrated) return;
 
  const privateKey = decryptWithServerKey(encryptedKey);
 
  const wallet = await privy.wallets().import({
    wallet: {
      entropy_type: 'private-key',
      chain_type: 'ethereum',
      address: user.wallet_address,
      private_key: privateKey,
    },
  });
 
  await db.users.update(userId, {signer_migrated: true});
}

The @privy-io/node SDK encrypts the key material automatically for secure transmission to the TEE.

Ethereum (raw private key):

const wallet = await privy.wallets().import({
  wallet: {
    entropy_type: 'private-key',
    chain_type: 'ethereum',
    address: '<wallet-address>',
    private_key: '<hex-encoded-private-key>',
  },
});

Solana (raw private key):

const wallet = await privy.wallets().import({
  wallet: {
    entropy_type: 'private-key',
    chain_type: 'solana',
    address: '<wallet-address>',
    private_key: '<base58-encoded-private-key>',
  },
});

HD wallet (mnemonic):

If you're migrating HD wallets with a BIP39 mnemonic:

const wallet = await privy.wallets().import({
  wallet: {
    entropy_type: 'hd',
    chain_type: 'ethereum',
    address: '<wallet-address>',
    private_key: '<bip39-mnemonic>',
    index: 0,
  },
});

You can optionally assign an owner, policies, or additional signers at import time:

const wallet = await privy.wallets().import({
  wallet: {
    entropy_type: 'private-key',
    chain_type: 'ethereum',
    address: '<wallet-address>',
    private_key: '<hex-encoded-private-key>',
  },
  owner_id: '<privy-user-id>',
  policy_ids: ['<policy-id>'],
  additional_signers: [{signer_id: '<signer-id>'}],
});

If you're not using Node.js, use the REST API directly. The flow is three steps:

1. Initialize — call /v1/wallets/import/init to get an encryption public key:

curl --request POST \
  --url https://api.privy.io/v1/wallets/import/init \
  --header 'Authorization: Basic <encoded-app-credentials>' \
  --header 'Content-Type: application/json' \
  --header 'privy-app-id: <privy-app-id>' \
  --data '{
    "address": "<wallet-address>",
    "chain_type": "ethereum",
    "entropy_type": "private-key",
    "encryption_type": "HPKE"
  }'

2. Encrypt — encrypt the private key using HPKE with the returned public key (KEM: DHKEM_P256_HKDF_SHA256, KDF: HKDF_SHA256, AEAD: CHACHA20_POLY1305).

3. Submit — call /v1/wallets/import/submit with the encrypted key:

curl --request POST \
  --url https://api.privy.io/v1/wallets/import/submit \
  --header 'Authorization: Basic <encoded-app-credentials>' \
  --header 'Content-Type: application/json' \
  --header 'privy-app-id: <privy-app-id>' \
  --data '{
    "wallet": {
      "address": "<wallet-address>",
      "chain_type": "ethereum",
      "entropy_type": "private-key",
      "encryption_type": "HPKE",
      "ciphertext": "<base64-encoded-encrypted-key>",
      "encapsulated_key": "<base64-encoded-encapsulated-key>"
    }
  }'

See the Privy key import docs for the full encryption example and HD wallet variant.

await db.users.update(userId, {signer_migrated: true});

Unlike the React migration, JWT migration does not require a user export/import step — unless your users also use other auth methods (email, Google, etc.) alongside JWT.

If JWT is your only auth method:

  • Deploy your updated app
  • Users authenticate via JWT with Privy going forward
  • Migration happens automatically on each user's first login
  • No Alchemy dashboard export needed

If you also use other auth methods:

  • You still need to export users from Alchemy and import into Privy (see React migration guide Step 4)
  • Follow the same Deploy → Export → Import sequence

After migration, follow the Privy guides for using embedded wallets:

Because JWT authentication is controlled by your server, both Alchemy and Privy can verify the same JWT. There's no user metadata to transfer — your server is the source of truth for user identity. The only thing being migrated is the private key material.

  • Client-side path: The private key is briefly available in client memory during the export→import handoff. This is different from the React SDK which uses encrypted TEE-to-TEE transfer.
  • Server-side path: The key leaves the client encrypted against your server's public key, is decrypted briefly on your server, then re-encrypted with HPKE by @privy-io/node before being sent to Privy's TEE. The plaintext is only briefly available on your server, never over the wire.

The React migration SDK detects migration need automatically via Privy metadata. In the JWT path, you own this logic entirely via your user database.

Can I use the React migration SDK with JWT auth?

The React migration SDK supports standard auth methods (email, Google, passkeys). If your users authenticated exclusively via JWT, use this guide instead. If you have a mix of JWT and standard auth users, you may need both paths.

Are JWTs one-time use?

No. JWTs are reusable until they expire. The same JWT can authenticate with both Alchemy and Privy in the same session.

What if my JWT has a short expiration?

Ensure the JWT is valid for the duration of the migration flow (authenticate with both providers + export + import). If your JWTs expire quickly, generate a fresh one at the start of the migration flow.

Do I need to change my JWT signing/verification setup?

You'll need to configure Privy to accept your JWTs (JWKS endpoint or public key, plus the user ID claim). Your JWT generation on the server side stays the same.

Which path should I choose — client-side or server-side?

If you already have a React or React Native app with Alchemy's client SDK, the client-side path is the most straightforward — it mirrors the flow you already have. If your architecture is backend-driven (e.g., your server holds keys or controls wallet operations), the server-side path keeps everything on the server without requiring a client SDK integration.

Was this page helpful?