integration-privy
solana-mobile/solana-mobile-skills
Add Privy authentication to Solana Expo Android apps with Sign-In-With-Solana and Mobile Wallet Adapter.
What is integration-privy?
Integrates Privy's user authentication layer with Solana Mobile Wallet Adapter on Android, enabling stable user accounts and server-verifiable sessions via Sign-In-With-Solana. Use this when you need persistent user identity across devices, backend session verification, or login methods beyond wallet connection alone.
- Mount PrivyProvider and MobileWalletProvider to enable Privy authentication
- Generate and sign SIWS messages using Mobile Wallet Adapter for wallet-based login
- Exchange signed messages for Privy sessions with server-verifiable JWT tokens
- Link wallets to existing Privy accounts for multi-device user management
- Read and validate Privy access tokens from backend services
- Handle initialization state and authentication flow with proper loading and error states
How to install integration-privy
npx skills add https://github.com/solana-mobile/solana-mobile-skills --skill integration-privy- Working useMobileWallet() hook from solana-mobile-wallet skill
- Development build on Android (Expo Go not supported)
- Privy account with dashboard access to create app and obtain App ID and Client ID
- @privy-io/expo and @privy-io/expo-native-extensions packages installed
- Environment variables EXPO_PUBLIC_PRIVY_APP_ID and EXPO_PUBLIC_PRIVY_CLIENT_ID configured
How to use integration-privy
- 1.Create a Privy app in the dashboard at https://dashboard.privy.io and enable SVM (Solana) wallets under User management > Authentication
- 2.Set the app identifier in Privy App settings > Clients to match your expo.android.package from app.json
- 3.Install @privy-io/expo and @privy-io/expo-native-extensions with all peer dependencies
- 4.Add crypto polyfills to your entry module, configure expo-secure-store and expo-web-browser in app.json plugins, and set up Metro resolver override for jose
- 5.Mount PrivyProvider and MobileWalletProvider in your root component with QueryClientProvider
- 6.Wait for usePrivy().isReady to be true before rendering authenticated state to avoid login screen flashing
- 7.Call useLoginWithSiws() to generate a SIWS message, sign it with useMobileWallet().signMessages(), and exchange it for a Privy session
- 8.Use usePrivy().getAccessToken() per request to retrieve server-verifiable JWT tokens
Use cases
- Building a Solana mobile app that needs persistent user accounts across multiple devices
- Implementing server-side session management for Solana mobile applications
- Adding traditional login methods (email, social) alongside wallet authentication
- Linking multiple wallets to a single user account for account recovery
- Debugging authentication issues in Privy + Mobile Wallet Adapter setups
- Mobile app developers building on Solana with Expo
- Backend developers implementing session verification for Solana mobile apps
- Teams needing persistent user identity beyond wallet connection
- Developers requiring multi-device account support on Solana mobile
integration-privy FAQ
Privy owns the user account and provides a server-verifiable JWT; Mobile Wallet Adapter owns the signing keys. Privy signs nothing — every signature comes from the wallet app. Together they provide both stable user identity and cryptographic proof.
Login will reject every SIWS attempt even though the wallet signed correctly. This toggle is easy to skip and expensive to debug — enable it under User management > Authentication in the External wallets card.
No. Mobile Wallet Adapter has no iOS support and does not run in Expo Go, which caps the whole integration to Android development builds only.
Pass account.address, which is base58. account.addressBase64 is MWA's wire format and Privy will not accept it. Similarly, use fromUint8Array to convert the signed payload to base64 for Privy, not base58.
No. Call getAccessToken() per request and never cache the result, as tokens may expire or be revoked between requests.
Full instructions (SKILL.md)
Source of truth, from solana-mobile/solana-mobile-skills.
name: integration-privy description: Add Privy authentication to a Solana Expo Android app on top of Mobile Wallet Adapter, using Sign-In-With-Solana. Use when installing @privy-io/expo, mounting PrivyProvider, logging a user in with useLoginWithSiws, linking a wallet to an existing Privy account, reading the Privy access token from a backend, or debugging a Privy plus MWA setup.
Privy on Solana mobile
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.
Before you start
| Requirement | Where it comes from |
|---|---|
A working useMobileWallet() | solana-mobile-wallet skill |
| A development build on Android | solana-mobile skill |
| A Privy app ID and client ID | The Privy dashboard — step 1 |
Step 1: create the Privy app
Do this first. Two of these values are compile-time environment variables, and one dashboard toggle decides whether login works at all.
- Sign in at https://dashboard.privy.io and click New app on the organization overview
- Name it, select Mobile app, create it, and save the App ID
- Under User management > Authentication, in the External wallets card, enable SVM (Solana) wallets
- Under App settings > Clients, set the app identifier to the
expo.android.packagevalue fromapp.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.
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.
Step 2: install and configure
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-storeandexpo-web-browserinapp.jsonplugins- A Metro resolver override so
joseresolves 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.
Step 3: mount the providers
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.
Step 4: wait for isReady
usePrivy() returns state that is meaningless until the SDK finishes reading stored tokens:
| Value | Type | Notes |
|---|---|---|
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 |
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.
Step 5: sign in with SIWS
The whole integration is this one sequence: generate, sign, exchange.
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:
- Pass
account.address, which is base58.account.addressBase64also 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-kithas already done that conversion for you. fromUint8Arrayproduces base64, not base58. It is a re-export ofjs-base64. Privy wants the base64 string here; base58 fails verification.- Do not slice the bytes.
signMessagesresolves 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.
Step 6: sign out of both
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.
Which side owns what
| Concern | Owner |
|---|---|
| Private keys and signing | The 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.
Reference material
- references/setup.md — polyfills, Metro config,
app.jsonplugins, 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:
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 onsolana-mobile— development builds, emulators, toolchain checksseeker-genesis-token— SIWS verified server-side without Privy, when a JWT is overkill
Links
- Privy Solana MWA recipe: https://docs.privy.io/recipes/solana/adding-solana-mwa
- Privy Expo SIWS login: https://docs.privy.io/guide/expo/authentication/siws
- Privy dashboard: https://dashboard.privy.io
Related skills
More from solana-mobile/solana-mobile-skills and the wider catalog.

seeker-connect
Connect web dapps on Seeker devices to their built-in Solana wallet via Wallet Standard.

seeker-domains
Resolve .skr domain names to Solana wallet addresses and vice versa in mobile apps.

seeker-genesis-token
Verify Seeker device ownership via Seeker Genesis Token (SGT) with Sign-in-with-Solana and server-side verification.

solana-mobile
Scaffold, configure, and troubleshoot Solana Mobile apps for Android using CLI, Expo, and React Native.

solana-mobile-publishing
Build, sign, and publish Android APKs to the Solana dApp Store with the dapp-store CLI.

solana-mobile-wallet
Connect Solana wallets and sign transactions in React Native Expo apps via Mobile Wallet Adapter.