Skip to main content

Command Palette

Search for a command to run...

Privy Wallet Architecture

Updated
10 min readView as Markdown
S

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 with 401 No valid authorization keys or user signing keys available. The fix is to own the wallet with your authorization key via ownerId (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