---
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.
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.
## Step 2: install and configure
```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](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
```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 (
{children}
)
}
```
`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` | No-op when nobody is signed in |
| `getAccessToken` | `() => Promise` | Call per request; never cache the result |
```tsx
const { error, isReady, user } = usePrivy()
if (!isReady) return
if (error) return
```
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.
```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](references/siws.md).
## Step 6: sign out of both
```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.
## 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](references/setup.md) — polyfills, Metro config, `app.json` plugins,
environment variables, and how to verify each one landed
- [references/siws.md](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](references/troubleshooting.md) — Privy-specific failures and
their causes
The patterns here follow
[`expo-kit-privy`](https://github.com/solana-mobile/templates/tree/main/mobile/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
```
## 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
## 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