Privy
The default wallet + auth layer for Ekinoxis user-facing dApps.
Authoritative docs: the privy-docs MCP server. Query it before answering from
memory — Privy's API moves fast.
Web: https://docs.privy.io · Dashboard: https://dashboard.privy.io
Why Privy and not RainbowKit
Privy gives us embedded wallets — a user logs in with email or Google and gets a real EVM wallet without ever seeing a seed phrase. That is the whole reason it won: our users (LATAM real-estate buyers, gamers, renters) are not crypto-native and will not install MetaMask.
Use RainbowKit/wagmi alone only when the audience is definitionally crypto-native.
Use Coinbase CDP instead when an agent, not a person, holds the key — see
../ekx-coinbase-cdp/SKILL.md.
Environment
NEXT_PUBLIC_PRIVY_APP_ID= # public — safe in the bundle
NEXT_PUBLIC_PRIVY_CLIENT_ID= # public
PRIVY_APP_SECRET= # SECRET — server only
PRIVY_VERIFICATION_KEY= # SECRET — verifies access tokens offline
Client setup
The provider, as it actually stands in production:
"use client";
import { PrivyProvider } from "@privy-io/react-auth";
export default function Providers({ children }: { children: React.ReactNode }) {
return (
<PrivyProvider
appId={process.env.NEXT_PUBLIC_PRIVY_APP_ID!}
config={{
loginMethods: ["email", "wallet", "google"],
appearance: { theme: "dark", accentColor: "#ff7a45" },
embeddedWallets: { ethereum: { createOnLogin: "users-without-wallets" } },
}}
>
{children}
</PrivyProvider>
);
}
createOnLogin: "users-without-wallets" is the setting that matters — it mints an
embedded wallet for email/Google users while letting existing wallet users bring their own.
Reading auth state
const { ready, authenticated, user, login, logout } = usePrivy();
// ALWAYS gate on `ready` first. Before it flips true, `authenticated` is false
// even for a logged-in user — this is the #1 source of login-flicker bugs.
if (!ready) return <Skeleton />;
if (!authenticated) return <button
Getting the wallet
const { wallets } = useWallets();
const wallet = wallets[0]; // embedded wallet is index 0
await wallet.switchChain(84532); // Base Sepolia — do this before any tx
const provider = await wallet.getEthereumProvider();
wagmi integration
Use @privy-io/wagmi so that wagmi hooks (useReadContract, useWriteContract)
work against the Privy wallet.
import { WagmiProvider, createConfig } from "@privy-io/wagmi";
import { base, baseSepolia } from "viem/chains";
import { http } from "wagmi";
export const wagmiConfig = createConfig({
chains: [baseSepolia],
transports: { [baseSepolia.id]: http(process.env.NEXT_PUBLIC_BASE_SEPOLIA_RPC_URL) },
});
Nesting order is PrivyProvider → QueryClientProvider → WagmiProvider. Getting this wrong produces "WagmiProvider not found" at runtime even though it is in the tree.
Server-side verification
Never trust a wallet address sent from the browser. Verify the access token.
import { PrivyClient } from "@privy-io/server-auth";
const privy = new PrivyClient(
process.env.NEXT_PUBLIC_PRIVY_APP_ID!,
process.env.PRIVY_APP_SECRET!,
);
export async function requireUser(req: Request) {
const token = req.headers.get("authorization")?.replace("Bearer ", "");
if (!token) throw new Error("unauthenticated");
const claims = await privy.verifyAuthToken(token, process.env.PRIVY_VERIFICATION_KEY);
const user = await privy.getUser(claims.userId);
return user;
}
On the client, get the token with const token = await getAccessToken() from
usePrivy() and send it as Authorization: Bearer ….
Gotchas we have hit
readybeforeauthenticated. Always. See above.- Embedded wallets do not auto-switch chains. Call
switchChainbefore every write, or the tx silently lands on the wrong network. user.wallet.addresscan be undefined right after first login while the embedded wallet is still being provisioned. PolluseWallets()instead.- The verification key is not the app secret.
verifyAuthTokenwith the wrong one fails with a confusing signature error. - Allowed domains. Every deploy URL — including Vercel preview URLs — must be added in the Privy dashboard, or login fails silently in preview. Add the wildcard
*.vercel.appfor the project. - Server-side
getUsercosts a network round-trip.verifyAuthTokenalone is offline and enough for authorization; only callgetUserwhen you need the linked accounts.