integration-privy

Compare original and translation side by side

🇺🇸

Original

English
🇨🇳

Translation

Chinese

Privy on Solana mobile

在Solana移动应用中集成Privy

Privy owns the user: a durable account identifier and a JWT a backend can verify. Mobile Wallet Adapter owns the keys. Privy signs nothing in this setup — every signature still comes from the wallet app.
Sign-In-With-Solana joins the two. Privy generates a message, MWA signs it, Privy exchanges the signature for a session.
Reach for this when an app needs a stable user record across devices, a server-verifiable session, or login methods beyond a wallet. An app that only needs a connected address does not need Privy — use the
solana-mobile-wallet
skill alone.
Android only, and a development build only. MWA has no iOS support and does not run in Expo Go, which caps the whole integration.
Privy负责用户身份:提供持久化的账户标识符和后端可验证的JWT。Mobile Wallet Adapter负责密钥。在此配置中,Privy不会进行任何签名操作——所有签名仍由钱包应用生成。
Sign-In-With-Solana(SIWS)将二者关联起来:Privy生成消息,MWA对其签名,Privy再用签名换取会话。
当应用需要跨设备的稳定用户记录、服务器可验证的会话,或钱包以外的登录方式时,可采用此方案。如果应用仅需连接地址,则无需Privy——单独使用
solana-mobile-wallet
技能即可。
仅支持Android,且仅适用于开发构建版本。MWA暂不支持iOS,也无法在Expo Go中运行,这限制了整个集成的适用范围。

Before you start

准备工作

RequirementWhere it comes from
A working
useMobileWallet()
solana-mobile-wallet
skill
A development build on Android
solana-mobile
skill
A Privy app ID and client IDThe Privy dashboard — step 1
要求获取途径
可用的
useMobileWallet()
solana-mobile-wallet
技能
Android开发构建版本
solana-mobile
技能
Privy应用ID和客户端IDPrivy控制台——步骤1

Step 1: create the Privy app

步骤1:创建Privy应用

Do this first. Two of these values are compile-time environment variables, and one dashboard toggle decides whether login works at all.
  1. Sign in at https://dashboard.privy.io and click New app on the organization overview
  2. Name it, select Mobile app, create it, and save the App ID
  3. Under User management > Authentication, in the External wallets card, enable SVM (Solana) wallets
  4. Under App settings > Clients, set the app identifier to the
    expo.android.package
    value from
    app.json
    , and save the Client ID
The SVM wallets toggle is the one that is easy to skip and expensive to debug — while it is off,
login
rejects every SIWS attempt even though the wallet signed correctly. The app identifier matters because Privy checks the calling app's package name against the client.
bash
EXPO_PUBLIC_PRIVY_APP_ID=your-privy-app-id
EXPO_PUBLIC_PRIVY_CLIENT_ID=your-privy-client-id
Both are public client-side identifiers, so
EXPO_PUBLIC_
is correct. The Privy app secret never belongs in a mobile app — anything prefixed
EXPO_PUBLIC_
is readable in the shipped bundle. The secret is for server code only.
请先完成此步骤。其中两个值是编译时环境变量,还有一个控制台开关直接决定登录功能是否可用。
  1. 登录https://dashboard.privy.io,在组织概览页面点击**New app**
  2. 命名应用,选择Mobile app,创建后保存App ID
  3. User management > Authentication下的External wallets卡片中,启用SVM (Solana) wallets
  4. App settings > Clients中,将应用标识符设置为
    app.json
    中的
    expo.android.package
    值,然后保存Client ID
SVM wallets开关很容易被忽略,但调试成本很高——如果关闭该开关,即使钱包签名正确,
login
也会拒绝所有SIWS请求。应用标识符至关重要,因为Privy会将调用应用的包名与客户端信息进行校验。
bash
EXPO_PUBLIC_PRIVY_APP_ID=your-privy-app-id
EXPO_PUBLIC_PRIVY_CLIENT_ID=your-privy-client-id
这两个都是公开的客户端标识符,因此使用
EXPO_PUBLIC_
前缀是正确的。Privy应用密钥绝对不能放入移动应用中——所有以
EXPO_PUBLIC_
为前缀的内容在打包后的应用中都是可读的,密钥仅适用于服务端代码。

Step 2: install and configure

步骤2:安装与配置

bash
npx expo install @privy-io/expo @privy-io/expo-native-extensions
@privy-io/expo
carries a long peer dependency list that shifts between releases — passkeys, secure store, web browser, crypto,
viem
. Install what the version you picked asks for rather than copying a list from anywhere, including from here.
Three pieces of native wiring are required, and the SDK fails in a different place for each:
  • Crypto and text-encoding polyfills, loaded from the entry module before anything else
  • expo-secure-store
    and
    expo-web-browser
    in
    app.json
    plugins
  • A Metro resolver override so
    jose
    resolves to its browser build
Full contents for each, and how to confirm they took: references/setup.md. Rebuild natively (
npx expo run:android
) after this step — a JS reload will not pick up the new native modules.
bash
npx expo install @privy-io/expo @privy-io/expo-native-extensions
@privy-io/expo
包含大量随版本变化的对等依赖——包括passkeys、secure store、web browser、crypto、
viem
等。请根据你选择的版本安装所需依赖,不要盲目复制任何列表(包括本文中的列表)。
需要完成三项原生配置,每项配置缺失都会导致SDK在不同环节失败:
  • 加密和文本编码polyfill,需在入口模块加载所有内容前引入
  • app.json
    的plugins中添加
    expo-secure-store
    expo-web-browser
  • 覆盖Metro解析器,使
    jose
    解析为其浏览器构建版本
各项配置的完整内容及验证方法:references/setup.md。完成此步骤后需重新构建原生应用(
npx expo run:android
)——仅重载JS不会加载新的原生模块。

Step 3: mount the providers

步骤3:挂载提供者组件

tsx
import { PrivyProvider } from '@privy-io/expo'
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
import { type AppIdentity, createSolanaDevnet, MobileWalletProvider } from '@wallet-ui/react-native-kit'
import type { ReactNode } from 'react'

const cluster = createSolanaDevnet()
const identity: AppIdentity = { name: 'My App', uri: 'myapp://myapp' }
const privyAppId = process.env.EXPO_PUBLIC_PRIVY_APP_ID
const privyClientId = process.env.EXPO_PUBLIC_PRIVY_CLIENT_ID
const queryClient = new QueryClient()

export function AppProviders({ children }: { children: ReactNode }) {
  if (!privyAppId || !privyClientId) {
    throw new Error('Missing Privy environment variables')
  }

  return (
    <QueryClientProvider client={queryClient}>
      <PrivyProvider appId={privyAppId} clientId={privyClientId}>
        <MobileWalletProvider cluster={cluster} identity={identity}>
          {children}
        </MobileWalletProvider>
      </PrivyProvider>
    </QueryClientProvider>
  )
}
PrivyProvider
and
MobileWalletProvider
do not depend on each other, so their relative nesting is free — but both must sit above every screen, and
QueryClientProvider
above both if the hooks below are queries and mutations.
Throwing on missing environment variables is deliberate. Undefined values reach Privy as a malformed app ID and surface much later as an opaque initialization error.
clientId
is typed optional in
PrivyProviderProps
, which is misleading here: Privy's mobile documentation treats it as required, and the dashboard issues one per mobile client. Pass it.
tsx
import { PrivyProvider } from '@privy-io/expo'
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
import { type AppIdentity, createSolanaDevnet, MobileWalletProvider } from '@wallet-ui/react-native-kit'
import type { ReactNode } from 'react'

const cluster = createSolanaDevnet()
const identity: AppIdentity = { name: 'My App', uri: 'myapp://myapp' }
const privyAppId = process.env.EXPO_PUBLIC_PRIVY_APP_ID
const privyClientId = process.env.EXPO_PUBLIC_PRIVY_CLIENT_ID
const queryClient = new QueryClient()

export function AppProviders({ children }: { children: ReactNode }) {
  if (!privyAppId || !privyClientId) {
    throw new Error('Missing Privy environment variables')
  }

  return (
    <QueryClientProvider client={queryClient}>
      <PrivyProvider appId={privyAppId} clientId={privyClientId}>
        <MobileWalletProvider cluster={cluster} identity={identity}>
          {children}
        </MobileWalletProvider>
      </PrivyProvider>
    </QueryClientProvider>
  )
}
PrivyProvider
MobileWalletProvider
互不依赖,因此它们的嵌套顺序可以自由调整——但两者必须位于所有页面组件之上;如果下方使用的钩子是查询或突变操作,
QueryClientProvider
则必须位于两者之上。
故意在缺失环境变量时抛出错误是合理的。未定义的值会被Privy视为格式错误的应用ID,并在后续流程中表现为模糊的初始化错误。
clientId
PrivyProviderProps
中被标记为可选,但这具有误导性:Privy的移动文档将其视为必填项,控制台会为每个移动客户端生成一个
clientId
,请务必传入。

Step 4: wait for
isReady

步骤4:等待
isReady
状态

usePrivy()
returns state that is meaningless until the SDK finishes reading stored tokens:
ValueTypeNotes
isReady
boolean
Everything else is provisional until this is
true
user
User | null
null
when unauthenticated — not
undefined
error
Error | null
Initialization failures, typically storage access
logout
() => Promise<void>
No-op when nobody is signed in
getAccessToken
() => Promise<string | null>
Call per request; never cache the result
tsx
const { error, isReady, user } = usePrivy()

if (!isReady) return <Loading />
if (error) return <ErrorCard message={error.message} />
Rendering a signed-out state while
isReady
is
false
makes an already-authenticated user flash through a login screen on every cold start.
在SDK完成存储令牌读取前,
usePrivy()
返回的状态是无意义的:
类型说明
isReady
boolean
在此值变为
true
前,其他所有状态都是临时的
user
User | null
未认证时为
null
——而非
undefined
error
Error | null
初始化失败,通常是存储访问问题
logout
() => Promise<void>
未登录时执行无操作
getAccessToken
() => Promise<string | null>
每次请求时调用;切勿缓存结果
tsx
const { error, isReady, user } = usePrivy()

if (!isReady) return <Loading />
if (error) return <ErrorCard message={error.message} />
如果在
isReady
false
时渲染未登录状态,会导致已认证用户在每次冷启动时短暂闪过登录界面。

Step 5: sign in with SIWS

步骤5:通过SIWS登录

The whole integration is this one sequence: generate, sign, exchange.
tsx
import { useLoginWithSiws } from '@privy-io/expo'
import type { Address } from '@solana/kit'
import { useMutation } from '@tanstack/react-query'
import { fromUint8Array, useMobileWallet } from '@wallet-ui/react-native-kit'

const siwsDomain = 'myapp.com'
const siwsUri = 'myapp://privy-login'

export function usePrivySignInMutation(address: Address) {
  const { generateMessage, login } = useLoginWithSiws()
  const { signMessages } = useMobileWallet()

  return useMutation({
    mutationFn: async () => {
      const { message } = await generateMessage({
        from: { domain: siwsDomain, uri: siwsUri },
        wallet: { address: address.toString() },
      })

      const signedPayload = await signMessages(new TextEncoder().encode(message))

      await login({ message, signature: fromUint8Array(signedPayload) })
    },
  })
}
Call it only once a wallet is connected —
useMobileWallet().account
must be defined, since
signMessages
triggers its own authorization otherwise.
Three encoding details decide whether this works:
  1. Pass
    account.address
    , which is base58.
    account.addressBase64
    also exists; it is MWA's wire format and Privy will not accept it. Privy's own recipe spends three lines converting base64 to base58 because it drives the raw protocol —
    @wallet-ui/react-native-kit
    has already done that conversion for you.
  2. fromUint8Array
    produces base64, not base58.
    It is a re-export of
    js-base64
    . Privy wants the base64 string here; base58 fails verification.
  3. Do not slice the bytes.
    signMessages
    resolves to MWA's signed payload, not a bare 64-byte signature. Base64-encode it whole and hand it over — the template and Privy's recipe both do exactly this.
from.domain
is an RFC 3986 authority: a bare host, no scheme and no path.
from.uri
is a full URI and is normally your app's deep link. Keep both stable — they are embedded in the signed message the user sees in their wallet.
Linking a wallet to an account that already exists, and verifying the session on a server: references/siws.md.
整个集成流程就是这三个步骤:生成消息、签名、换取会话。
tsx
import { useLoginWithSiws } from '@privy-io/expo'
import type { Address } from '@solana/kit'
import { useMutation } from '@tanstack/react-query'
import { fromUint8Array, useMobileWallet } from '@wallet-ui/react-native-kit'

const siwsDomain = 'myapp.com'
const siwsUri = 'myapp://privy-login'

export function usePrivySignInMutation(address: Address) {
  const { generateMessage, login } = useLoginWithSiws()
  const { signMessages } = useMobileWallet()

  return useMutation({
    mutationFn: async () => {
      const { message } = await generateMessage({
        from: { domain: siwsDomain, uri: siwsUri },
        wallet: { address: address.toString() },
      })

      const signedPayload = await signMessages(new TextEncoder().encode(message))

      await login({ message, signature: fromUint8Array(signedPayload) })
    },
  })
}
仅在钱包已连接时调用此函数——
useMobileWallet().account
必须已定义,否则
signMessages
会触发自身的授权流程。
以下三个编码细节决定了流程是否能成功:
  1. 传入
    account.address
    ,即base58格式
    。还存在
    account.addressBase64
    格式,这是MWA的有线格式,但Privy不接受。Privy官方示例中用三行代码将base64转换为base58以适配原始协议——而
    @wallet-ui/react-native-kit
    已经为你完成了这个转换。
  2. fromUint8Array
    生成base64格式,而非base58
    。它是
    js-base64
    的重导出。Privy在此处需要base64字符串,base58会导致验证失败。
  3. 不要截取字节
    signMessages
    返回的是MWA的签名载荷,而非单纯的64字节签名。直接对其进行base64编码并传入——本文模板和Privy官方示例均采用此方式。
from.domain
是RFC 3986标准的权限部分:仅为裸主机名,不包含协议和路径。
from.uri
是完整URI,通常为你的应用深度链接。请保持两者稳定——它们会嵌入用户在钱包中看到的签名消息里。
将钱包关联至已有账户,以及在服务端验证会话的方法:references/siws.md

Step 6: sign out of both

步骤6:同时退出两者

tsx
const { logout } = usePrivy()
const { disconnect } = useMobileWallet()

await logout()
await disconnect()
Doing one without the other leaves the app in a half-signed-out state.
disconnect()
alone keeps a live Privy session with no wallet behind it;
logout()
alone leaves the wallet authorized and re-signs in silently on the next attempt.
tsx
const { logout } = usePrivy()
const { disconnect } = useMobileWallet()

await logout()
await disconnect()
仅退出其中一个会导致应用处于半退出状态。仅调用
disconnect()
会保留有效的Privy会话,但背后没有钱包;仅调用
logout()
会保留钱包授权,下次尝试时会自动静默登录。

Which side owns what

职责划分

ConcernOwner
Private keys and signingThe wallet app, over MWA
Connected address
useMobileWallet().account
User identity across devices
usePrivy().user
Server-verifiable session
usePrivy().getAccessToken()
Sending transactions
useMobileWallet().sendTransactions
There is no Privy signer in this setup. A user is signed in to Privy and connected over MWA as two independent facts, and the UI has to handle every combination — most usefully "connected but not signed in", which is where the sign-in button belongs.
事项负责方
私钥与签名操作钱包应用,通过MWA实现
已连接地址
useMobileWallet().account
跨设备用户身份
usePrivy().user
服务器可验证会话
usePrivy().getAccessToken()
发送交易
useMobileWallet().sendTransactions
此配置中没有Privy签名器。用户登录Privy和通过MWA连接是两个独立的事实,UI需要处理所有组合情况——最常见的是“已连接但未登录”,登录按钮应放置在此场景下。

Reference material

参考资料

  • references/setup.md — polyfills, Metro config,
    app.json
    plugins, environment variables, and how to verify each one landed
  • references/siws.md — the SIWS exchange in depth, linking additional wallets, server-side token verification, and the raw-protocol variant without Wallet UI
  • references/troubleshooting.md — Privy-specific failures and their causes
The patterns here follow
expo-kit-privy
, a complete working app. Read it when this file is ambiguous:
bash
npx solana-mobile@latest create /tmp/reference-app --template expo-kit-privy --skip-install
  • references/setup.md — polyfill、Metro配置、
    app.json
    插件、环境变量,以及验证各项配置是否生效的方法
  • references/siws.md — 深入讲解SIWS交互流程、关联额外钱包、服务端令牌验证,以及不使用Wallet UI的原始协议变体
  • references/troubleshooting.md — Privy相关故障及其原因
本文中的模式遵循
expo-kit-privy
,这是一个完整的可运行应用。如果本文内容存在歧义,可参考该应用:
bash
npx solana-mobile@latest create /tmp/reference-app --template expo-kit-privy --skip-install

Related skills

相关技能

  • solana-mobile-wallet
    — MWA connection, signing, and sending, which this builds on
  • solana-mobile
    — development builds, emulators, toolchain checks
  • seeker-genesis-token
    — SIWS verified server-side without Privy, when a JWT is overkill
  • solana-mobile-wallet
    — MWA连接、签名和交易发送,是本文内容的基础
  • solana-mobile
    — 开发构建版本、模拟器、工具链检查
  • seeker-genesis-token
    — 不使用Privy的服务端SIWS验证,适用于无需JWT的场景

Links

链接