Privy Wallet Architecture
Technology Enthusiast and voracious reader with a demonstrated history of working in the computer software industry. Skilled in PHP, JavaScript, NodeJS, Angular, MySQL, MongoDB, Web3, Product Development, Project Management, and Teamwork.
A deep-dive into how Privy wallets work — both embedded (self-custodial) and server (custodial) wallets — including architecture diagrams, key-management model, signing/authorization flows, and how each is implemented. The server-wallet sections map directly to what this repo builds (backend/ + frontend/).
1. TL;DR — the two custody models
| Embedded wallet | Server wallet | |
|---|---|---|
| Who can sign | The end user (authenticated session) | Your backend (authorization key) |
| Custody | Self-custodial (non-custodial) | Custodial (you control) |
| Key material | Split / secured so only the user can authorize | Held in Privy TEEs; actions authorized by your key |
| SDK | @privy-io/react-auth (client) |
@privy-io/server-auth (backend) |
| Created | On user login, in the browser | Programmatically from your server |
| User needed per tx? | Yes — user authorizes | No — server signs unilaterally |
| Best for | Consumer self-custody UX | Platform-managed wallets, automation, "wallet-as-a-feature" |
Rule of thumb: if your platform must be able to move funds without the user present, you need server wallets owned by an authorization key. That is the custodial model this project implements.
2. Where wallets live: the trust boundary
Both wallet types keep the raw private key out of your app and out of your database. Keys are generated and used inside Privy's secure infrastructure (TEEs — Trusted Execution Environments). What differs is who is allowed to authorize a signature.
flowchart TB
subgraph Client["🖥️ Browser / Your Frontend"]
FE["React app<br/>@privy-io/react-auth"]
end
subgraph YourBackend["🏢 Your Backend (trusted)"]
BE["NestJS API<br/>@privy-io/server-auth<br/>APP_SECRET + AUTH_KEY"]
end
subgraph Privy["🔐 Privy Infrastructure"]
API["Privy API"]
TEE["TEE / secure enclave<br/>(raw private keys never leave)"]
API --- TEE
end
subgraph Chain["⛓️ Blockchain"]
RPC["RPC / network<br/>(e.g. Hoodi eip155:560048)"]
end
FE -- "embedded: user authorizes" --> API
BE -- "server: authorization-key signs" --> API
TEE -- "broadcasts signed tx" --> RPC
The private key is never exposed to your servers, your database, or the browser in plaintext — you interact with the wallet through authorized API calls.
3. Embedded wallets (self-custodial)
3.1 What they are
A wallet provisioned for the user, controlled by the user. Privy secures the key so that only the authenticated user (their session/device/passkey) can authorize its use. Your app — and Privy — cannot unilaterally move funds. This is the model behind "sign in and you magically have a wallet, no seed phrase."
3.2 Key-management model
Privy protects the key with a combination of secure enclaves (TEEs) and key-splitting so that no single party holds a usable key. Conceptually the authority to use the key is gated by the user's authenticated session.
flowchart LR
subgraph User["Authenticated user"]
S["Session / passkey / device"]
end
subgraph PrivyTEE["Privy TEE"]
K["Private key<br/>(usable only when the user authorizes)"]
end
S -- "authorizes signature" --> K
K -- "returns signature" --> S
style K fill:#eef,stroke:#66f
Non-custodial: the user is the owner; the key is bound to their auth.
Recoverable: via the login method + optional recovery (passkey, password).
Exportable: the user can export their private key (they truly own it).
3.3 Login → wallet → sign flow
sequenceDiagram
autonumber
participant U as User
participant FE as Frontend (react-auth)
participant P as Privy
U->>FE: Click "Log in" (email / Google / passkey)
FE->>P: Authenticate
P-->>FE: Authenticated (user DID, session)
Note over FE,P: createOnLogin provisions an embedded wallet
P-->>FE: Embedded wallet ready (address)
U->>FE: Action needs a signature (sign / send)
FE->>P: Request signature (user session authorizes)
P-->>FE: Signature / tx hash
FE-->>U: Done
3.4 Implementation (client-side)
Enable creation on login and let the SDK manage everything in the browser:
// main.tsx
<PrivyProvider
appId={appId}
config={{
loginMethods: ['email', 'google', 'passkey'],
embeddedWallets: {
ethereum: { createOnLogin: 'users-without-wallets' }, // auto-provision
},
}}
>
<App />
</PrivyProvider>
// Using the wallet in a component
import { usePrivy, useWallets } from '@privy-io/react-auth';
const { wallets } = useWallets();
const wallet = wallets[0];
const provider = await wallet.getEthereumProvider();
await provider.request({
method: 'eth_sendTransaction',
params: [{ to, value }], // user is prompted / authorizes
});
In this repo embedded wallet creation is deliberately disabled (
createOnLogin: 'off') because we provision server wallets instead — see §4.
4. Server wallets (custodial) — what this repo builds
4.1 What they are
Wallets your backend creates and controls. The wallet's owner is a server authorization key (a P-256 key held by you). Your backend signs actions with the authorization private key — no user signature required per transaction. That is what makes it custodial.
4.2 The three owner options (critical)
When creating a wallet you choose its owner — this decides who can sign:
flowchart TB
W["New wallet"] --> Q{"owner = ?"}
Q -->|"owner.user_id"| U["👤 User-owned<br/>only the USER can sign<br/>(needs user JWT / session)"]
Q -->|"owner.public_key"| PK["🔑 secp256k1 chain key<br/>(external signer)"]
Q -->|"owner_id (key quorum id)"| AK["🏢 Authorization-key owned<br/>your SERVER signs<br/>✅ custodial"]
style AK fill:#e8f8ee,stroke:#0a7d3b
style U fill:#fdecec,stroke:#c33
This was the crux of the debugging in this project. A wallet created with
owner: { userId }can only be signed by the user → server sends fail with401 No valid authorization keys or user signing keys available. The fix is to own the wallet with your authorization key viaownerId(the key-quorum id).
4.3 Authorization keys & key quorums
An authorization key is a P-256 keypair. Its id (a key-quorum id) is used as
ownerId; its private key signs requests (configured on the SDK client).A key quorum can hold multiple keys with a threshold (m-of-n) — e.g. require 2 of 3 backend keys, or (user key OR server key) as 1-of-2. This is how you harden custody.
flowchart LR
subgraph Quorum["Owner = Key Quorum (m-of-n)"]
A["Auth key A (server)"]
B["Auth key B (server)"]
C["User key"]
end
Quorum -->|"threshold met"| Sign["✅ Signature authorized"]
4.4 Register → provision → send flow (custodial)
sequenceDiagram
autonumber
participant U as User
participant FE as Frontend
participant BE as Your Backend
participant P as Privy
participant C as Chain (Hoodi)
U->>FE: Log in (Google / email OTP)
FE->>P: Authenticate
P-->>FE: access token (user DID)
FE->>BE: POST /auth/sync (Bearer access token)
BE->>P: verifyAuthToken(token)
P-->>BE: { userId }
BE->>P: walletApi.createWallet({ chainType, ownerId: AUTH_KEY })
P-->>BE: { id, address } (owned by your authorization key)
BE-->>FE: { user, wallet, chain }
U->>FE: Send 0.01 ETH to 0x…
FE->>BE: POST /wallet/send { to, amount }
BE->>P: walletApi.ethereum.sendTransaction({ walletId, caip2, transaction })
Note over BE,P: signed with AUTHORIZATION_PRIVATE_KEY (no user needed)
P->>C: broadcast
C-->>P: tx hash
P-->>BE: { hash }
BE-->>FE: { hash, explorerUrl }
4.5 Implementation (backend)
Client init — load the authorization private key so the SDK can sign:
// privy.service.ts
this.client = new PrivyClient(appId, appSecret, {
timeout: 60000,
walletApi: { authorizationPrivateKey: process.env.PRIVY_AUTHORIZATION_PRIVATE_KEY },
});
Create a custodial wallet owned by the authorization key (key quorum id):
const ownerId = process.env.PRIVY_AUTHORIZATION_KEY_ID; // key-quorum id
const wallet = await this.client.walletApi.createWallet({ chainType, ownerId });
// wallet.id, wallet.address → store mapping user -> wallet in YOUR db
Send a transaction — server signs, no user in the loop:
const { hash } = await this.client.walletApi.ethereum.sendTransaction({
walletId,
caip2: 'eip155:560048', // Hoodi testnet
transaction: { to, value: `0x${wei.toString(16)}` },
});
Read balance (via your own RPC, not Privy):
const wei = await publicClient.getBalance({ address }); // viem + Hoodi RPC
4.6 Guardrails: policies
Attach a policy to a wallet (policyIds) to constrain what the authorization key may do — recipient allowlists, max amount, contract/method restrictions. A policy does not grant signing authority (that's the owner/authorization key); it limits an already-authorized signer.
await this.client.walletApi.createWallet({
chainType,
ownerId,
policyIds: ['<policy-id>'], // e.g. cap amount, allowlist recipients
});
5. Side-by-side: request authorization
flowchart TB
subgraph E["Embedded (self-custodial)"]
direction TB
e1["User session"] --> e2["authorizes"] --> e3["Privy signs"]
end
subgraph S["Server (custodial)"]
direction TB
s1["Backend"] --> s2["authorization key signs request"] --> s3["Privy signs"]
end
| Dimension | Embedded | Server |
|---|---|---|
| Signing authority | User's authenticated session | Your authorization key (P-256) |
| Where signing is triggered | Browser | Backend |
| Can your platform act alone? | ❌ No | ✅ Yes |
| Where the secret lives | Nothing sensitive in your app | AUTHORIZATION_PRIVATE_KEY on server (guard like a root secret) |
| Regulatory weight | Lower (user holds funds) | Higher (you hold custody) |
| Typical use | Consumer self-custody | Fleets, automation, backend-driven payments |
6. Security model & production hardening
Embedded
Key bound to user auth; recoverable via login method + recovery factor (passkey/password).
Compromise scope is a single user, gated by their session.
Server (custodial)
Your authorization private key is the master control — anyone with it can move funds in every wallet it owns. Treat it like a root credential:
Store in a KMS/HSM/secrets manager, never a committed
.env.Prefer a key quorum (m-of-n) so one leaked key isn't enough.
Enforce policies (amount caps, allowlists) as a second line of defense.
Rotate keys; log and monitor every signing request; add rate limits.
Custody = legal weight. Holding user assets may trigger money-transmitter / VASP obligations depending on jurisdiction — validate before launch.
7. This project's architecture (as built)
Server (custodial) wallets, Google + email-OTP login, Hoodi testnet.
flowchart LR
subgraph FE["frontend/ (Vite + React 19)"]
L["LoginScreen<br/>Google / email OTP"]
WA["WalletActions<br/>balance + send"]
end
subgraph BE["backend/ (NestJS 11)"]
G["PrivyAuthGuard<br/>verifyAuthToken"]
AUTH["AuthService<br/>sync + provision"]
WAL["WalletService<br/>balance / send"]
US["UsersService<br/>data/users.json"]
end
subgraph P["Privy (server-auth)"]
WAPI["walletApi<br/>ownerId = auth key"]
end
RPC["Hoodi RPC<br/>eip155:560048"]
L -->|access token| G
WA -->|Bearer token| G
G --> AUTH --> WAPI
AUTH --> US
WA --> WAL
WAL -->|sendTransaction| WAPI
WAL -->|getBalance| RPC
WAPI -->|broadcast| RPC
Key components
backend/src/privy/privy.service.ts— Privy client,createWallet({ ownerId }),sendTransaction.backend/src/auth/*— verify token, sync user, idempotent wallet provisioning.backend/src/wallet/*— balance (viem RPC) + send endpoints.backend/src/users/users.service.ts— persistent user→wallet mapping (data/users.json).frontend/src/LoginScreen.tsx,WalletActions.tsx— auth + balance/send UI.
Custody config (env)
| Var | Role |
|---|---|
PRIVY_APP_ID / PRIVY_APP_SECRET |
App identity (server-only secret) |
PRIVY_AUTHORIZATION_KEY_ID |
Key-quorum id → wallet ownerId |
PRIVY_AUTHORIZATION_PRIVATE_KEY |
Signs wallet RPC requests |
EVM_CHAIN_* |
Active network (Hoodi 560048) |
8. Decision guide
flowchart TD
Q1{"Must your platform move<br/>funds without the user?"}
Q1 -->|No| E["Embedded wallets<br/>(self-custodial)"]
Q1 -->|Yes| Q2{"One shared treasury<br/>or one wallet per user?"}
Q2 -->|Per user| SV["Server wallets, ownerId = auth key<br/>+ policies + quorum"]
Q2 -->|Treasury| TR["Server wallet(s)<br/>owned by a key quorum"]
style E fill:#eef,stroke:#66f
style SV fill:#e8f8ee,stroke:#0a7d3b
Want users to truly own their wallet, no custody liability? → Embedded.
Building a platform that manages wallets for a set of users (this project)? → Server wallets owned by an authorization key, hardened with a quorum + policies.
9. References
Privy wallets overview — https://docs.privy.io/wallets/overview
Create a wallet (API) — https://docs.privy.io/api-reference/wallets/create
Owners, authorization keys & key quorums — https://docs.privy.io/controls/authorization-keys/owners/types
Signing on the server — https://docs.privy.io/controls/authorization-keys/using-owners/sign/signing-on-the-server
Gas sponsorship — https://docs.privy.io/wallets/gas-and-asset-management/gas/overview