Many apps want to give users self-custodial wallets while ensuring the server must approve every
transaction, wallet update, and key export. A 2-of-2 key quorum achieves this: one quorum
member is the user, the other is an authorization key controlled by your server. Both must sign
every request to Privy’s API.
This means that even if a user’s account is compromised, an attacker cannot take unilateral action
with the wallet. Equally, your server alone cannot move funds without the user’s consent.
At a high-level, you will:
Create a server authorization key
Generate a P-256 keypair and register the public key with Privy. Your server holds the private
key and uses it to co-sign every request.
Create a 2-of-2 key quorum
Register a key quorum that contains the user ID and the server authorization key, with an
authorization_threshold of 2.
Create a wallet owned by the quorum
Create a wallet whose owner is the key quorum. All subsequent actions on this wallet require
both signatures.
Execute transactions with both signatures
For each request, collect the user’s JWT, obtain a user signing key, sign with the server
authorization key, and send both signatures to the Privy API.
1. Create a server authorization key
Your server needs a P-256 keypair. The private key stays on your server; the public key is
registered with Privy so it can verify your server’s signatures.
Save the private key securely (e.g. in an environment variable or secrets manager). You will
reference it as serverAuthorizationPrivateKey in later steps.
2. Create a 2-of-2 key quorum
Once you have the server authorization key’s public key and the user’s Privy user ID, register a
key quorum that requires both to sign.
Key quorums containing both user IDs and authorization keys must be created via the SDK or REST
API. The Dashboard only supports pure authorization-key quorums.
3. Create a wallet owned by the quorum
Create a wallet and set its owner_id to the key quorum ID from step 2.
Attach policies to the wallet when creating it to further restrict
which transactions are allowed, independent of the co-signing requirement.
4. Execute transactions with both signatures
Every request to the Privy API that acts on this wallet must include signatures from both the user
and the server. The flow below applies to transactions, wallet updates, and key export.
How it works
Client Your server Privy API
| | |
|-- (1) Build request payload | |
|-- (2) Sign with user key | |
| (useAuthorizationSignature) | |
| | |
|-- (3) Send payload + user sig -->| |
| |-- (4) Sign with server key |
| |-- (5) Send request + |
| | both sigs ------------->|
| |<-- response ------------------|
Step-by-step
Build the request payload on the client
Construct the JSON payload describing the request your app intends to make to the Privy API.
This payload includes the target URL, HTTP method, required headers, and request body.const requestPayload = {
version: 1,
url: `https://api.privy.io/v1/wallets/${walletId}/rpc`,
method: 'POST',
headers: {
'privy-app-id': 'insert-your-app-id'
},
body: {
caip2: 'eip155:1',
method: 'eth_sendTransaction',
chain_type: 'ethereum',
params: {
transaction: {
to: '0xRecipientAddress',
value: '0x2386f26fc10000',
data: '0x'
}
}
}
} as const;
const requestPayload = {
version: 1,
url: `https://api.privy.io/v1/wallets/${walletId}/rpc`,
method: 'POST',
headers: {
'privy-app-id': 'insert-your-app-id'
},
body: {
caip2: 'eip155:1',
method: 'eth_sendTransaction',
chain_type: 'ethereum',
params: {
transaction: {
to: '0xRecipientAddress',
value: '0x2386f26fc10000',
data: '0x'
}
}
}
} as const;
Sign the payload with the user's key on the client
Use the useAuthorizationSignature hook to sign the payload with the authenticated user’s
signing key. The hook handles key retrieval and signing entirely on the client — the user’s
private key never leaves the device.import {useAuthorizationSignature} from '@privy-io/react-auth';
const {generateAuthorizationSignature} = useAuthorizationSignature();
const {signature: userSignature} = await generateAuthorizationSignature(requestPayload);
import {useAuthorizationSignature} from '@privy-io/expo';
const {generateAuthorizationSignature} = useAuthorizationSignature();
const {signature: userSignature} = await generateAuthorizationSignature(requestPayload);
Send the payload and user signature to your server
Forward both the request payload and the user’s signature to your server. Your server will add
its own signature before proxying the request to the Privy API.await fetch('https://your-server.com/api/wallet-action', {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({
requestPayload,
userSignature
})
});
Sign with the server key and send to Privy
On your server, generate the server’s authorization signature over the same payload, then send
the request to the Privy API with both signatures as a comma-delimited value in the
privy-authorization-signature header.
Privy validates that both signatures are present, valid, and correspond to members of the wallet’s
key quorum. If either signature is missing or invalid, the request is rejected.
Learn more