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将二者关联起来:Privy生成消息,MWA对其签名,Privy再用签名换取会话。
当应用需要跨设备的稳定用户记录、服务器可验证的会话,或钱包之外的登录方式时,可采用此方案。如果应用仅需连接地址,则无需Privy——单独使用
solana-mobile-wallet
技能即可。
仅支持安卓平台,且仅适用于开发构建版本。 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
技能
安卓平台的开发构建包
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.
usePrivy()
返回的状态在SDK完成存储令牌读取前是无效的:
类型说明
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

链接