Passkeys provide a secure way to authorize actions on Privy wallets. This guide shows how to integrate your existing passkey implementation as an authorization mechanism for Privy wallets, combining modern authentication with powerful onchain actions.
Authorization keys provide a way to ensure that actions taken by your app’s wallets can only be authorized by an explicit user request. When you specify an owner of a resource, all requests to update that resource must be signed with this key. This security measure verifies that each request comes from your authorized passkey owner and helps prevent unauthorized operations.
If you need a passkey implementation set up for your application, we recommend using the simpleWebAuthn SDKs, which provides simple passkey registration and authentication flows.
Sample passkey registration flow
If you have not already done so, install the dependencies necessary for a simple passkey integration.
sh npm install @simplewebauthn/server @simplewebauthn/browser
// /api/register-passkey/beginimport {generateRegistrationOptions} from '@simplewebauthn/server';import type {NextApiRequest, NextApiResponse} from 'next';import {passkeyStorage} from '@/lib/passkey-storage';// This would typically come from your databaseconst rpName = 'Your App Name';const rpID = 'yourdomain.com';const origin = 'https://yourdomain.com';export default async function handler(req: NextApiRequest, res: NextApiResponse) { if (req.method !== 'POST') { return res.status(405).json({error: 'Method not allowed'}); } try { const {userId} = req.body; if (!userId) { return res.status(400).json({error: 'userId is required'}); } // Generate registration options const options = await generateRegistrationOptions({ rpName, rpID, userID: Buffer.from(userId), userName: `user-${userId}`, // This would typically be the user's email or username attestationType: 'none', // For demo purposes, we don't need attestation authenticatorSelection: { residentKey: 'preferred', userVerification: 'preferred' }, supportedAlgorithmIDs: [-7] // ES256 }); // Store the options temporarily (in production, store in database with expiration) passkeyStorage.set(userId, {options}); // Clean up old entries (older than 5 minutes) // passkeyStorage.cleanup(); return res.status(200).json(options); } catch (error) { console.error('Error generating registration options:', error); return res.status(500).json({error: 'Internal server error'}); }}
Next, create the registration verify endpoint:
Report incorrect code
Copy
Ask AI
// /api/register-passkey/verifyimport {verifyRegistrationResponse} from '@simplewebauthn/server';import type {NextApiRequest, NextApiResponse} from 'next';import {passkeyStorage} from '@/lib/passkey-storage';const rpID = 'yourdomain.com';const origin = 'https://yourdomain.com';export default async function handler(req: NextApiRequest, res: NextApiResponse) { if (req.method !== 'POST') { return res.status(405).json({error: 'Method not allowed'}); } try { const {userId, attestationResponse} = req.body; if (!userId || !attestationResponse) { return res.status(400).json({error: 'userId and attestationResponse are required'}); } // Retrieve the stored registration options const storedData = passkeyStorage.get(userId); if (!storedData) { return res.status(400).json({error: 'Registration options not found or expired'}); } const {options} = storedData; // Verify the registration response const verification = await verifyRegistrationResponse({ response: attestationResponse, expectedRPID: rpID, expectedOrigin: origin, expectedChallenge: options.challenge, requireUserVerification: false // For demo purposes }); if (verification.verified) { // Registration successful - store the passkey credentials // In production, you would store this in your database const {credentialID, credentialPublicKey} = verification.registrationInfo; // Clean up temporary storage passkeyStorage.delete(userId); return res.status(200).json({ verified: true, credentialID: Buffer.from(credentialID).toString('base64'), publicKey: Buffer.from(credentialPublicKey).toString('base64') }); } else { return res.status(400).json({error: 'Registration verification failed'}); } } catch (error) { console.error('Error verifying registration:', error); return res.status(500).json({error: 'Internal server error'}); }}
Creating and registering wallets with passkey authorization
Follow these steps to create a wallet and register it with a user’s passkey for authorization.
Retrieve the user’s passkey P-256 PEM-formatted public key and send it to your backend.
Converting WebAuthn public key to PEM format (if using simpleWebAuthn)
After registering a passkey, you’ll need to convert the WebAuthn public key from COSE format to PEM format that Privy expects:
Report incorrect code
Copy
Ask AI
const coseToJwk = require('cose-to-jwk');async function convertCoseToPem(coseKey: Uint8Array): Promise<string> { // Convert COSE to JWK format const jwk = coseToJwk(coseKey); // Import the JWK using Web Crypto API const cryptoKey = await crypto.subtle.importKey( 'jwk', jwk, { name: 'ECDSA', namedCurve: 'P-256' }, true, // extractable ['verify'] // only need verify for public keys ); // Export the key in SPKI format (DER-encoded) const spkiDer = await crypto.subtle.exportKey('spki', cryptoKey); // Convert to base64 and format as PEM const base64Key = Buffer.from(spkiDer).toString('base64'); // Split into 64-character lines const lines = []; for (let i = 0; i < base64Key.length; i += 64) { lines.push(base64Key.slice(i, i + 64)); } // Create PEM format const pemKey = `-----BEGIN PUBLIC KEY-----\n${lines.join('\n')}\n-----END PUBLIC KEY-----`; return pemKey;}
Use this function after successful passkey registration to get the PEM-formatted public key that Privy requires.
From your backend, call the Privy API to create a wallet with that P-256 public key as the owner. You can do this via the Privy SDK (below) or by hitting the Privy API directly.
Report incorrect code
Copy
Ask AI
import {PrivyClient} from '@privy-io/node';// Note: Privy expects the public key to be base64 encoded. Browsers return the key in base64url format, so be sure to convert it to base64 before passing it to the Privy SDK.const passkeyP256PublicKey = 'your-p256-public-key';const privy = new PrivyClient({appId: 'your-app-id', appSecret: 'your-app-secret'});const wallet = await privy.wallets().create({ owner: { public_key: passkeyP256PublicKey }, chain_type: 'ethereum' // or another supported chain type});
Associate the returned wallet ID with the user on your backend for use in future requests.
Below are the steps necessary to create a transaction request, have the user sign it with their passkey using WebAuthn, and submit the signed request to Privy:
Create and format the transaction request payload
Create your transaction and format it into the required request payload structure:
Report incorrect code
Copy
Ask AI
import canonicalize from 'canonicalize';import {startAuthentication} from '@simplewebauthn/browser';// Your transaction detailsconst transaction = { to: '0x...', value: '1000000000000000000', // 1 ETH in wei chain_id: 1, // Ethereum mainnet data: '0x', gas_limit: '21000', nonce: 42, type: 2};const serverWallet = {id: 'your-wallet-id'}; // The wallet created earlier// Format the request payload for Privy APIconst requestPayload = { version: 1, method: 'POST', url: `https://api.privy.io/v1/wallets/${serverWallet.id}/rpc`, body: { method: 'eth_sendTransaction', params: { transaction: { to: transaction.to, value: transaction.value, chain_id: transaction.chain_id, data: transaction.data, gas_limit: transaction.gas_limit, nonce: transaction.nonce, type: 2 } } }, headers: { 'privy-app-id': process.env.NEXT_PUBLIC_PRIVY_APP_ID! }};// Canonicalize the payload for consistent signingconst canonicalPayload = canonicalize(requestPayload) as string;// Convert to base64url as required by Privy's specificationfunction base64UrlEncode(str: string) { return btoa(str).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '');}const payloadBase64Url = base64UrlEncode(canonicalPayload);
Sign the payload with the user’s passkey
Use the WebAuthn authentication flow to sign the formatted payload:
Report incorrect code
Copy
Ask AI
import {startAuthentication} from '@simplewebauthn/browser';// Sign the payload using simpleWebAuthnconst authResponse = await startAuthentication({ optionsJSON: { challenge: '$PAYLOAD_BASE64_URL', allowCredentials: [], // Empty to discover available credentials userVerification: 'preferred', rpId: 'yourdomain.com', // Must match the rpID from registration timeout: 60000 // 60 seconds }});// Extract the WebAuthn response componentsconst signature = authResponse.response.signature;const authenticatorData = authResponse.response.authenticatorData;const clientDataJSON = authResponse.response.clientDataJSON;
Format the authorization signature
Create the specially formatted authorization signature that Privy expects:
Report incorrect code
Copy
Ask AI
// Format the authorization signature for Privyconst authorizationSignature = `webauthn:${authenticatorData}:${clientDataJSON}:${signature}`;
Send the transaction to Privy
Direct API
Node SDK
Send the transaction request with the WebAuthn authorization signature using direct API calls:
Report incorrect code
Copy
Ask AI
import fetch from 'node-fetch'; // or use global fetchconst accessToken = 'your-access-token'; // Your app's access tokenconst serverWallet = {id: 'your-wallet-id'}; // The wallet created earlierconst authorizationSignature = 'formatted-authorization-signature'; // From previous stepconst transaction = { to: '0x...', value: '1000000000000000000', // 1 ETH in wei chain_id: 1, // Ethereum mainnet data: '0x', gas_limit: '21000', nonce: 42, type: 2};// Send the transaction to Privy APIconst response = await fetch(`https://api.privy.io/v1/wallets/${serverWallet.id}/rpc`, { method: 'POST', headers: { 'Content-Type': 'application/json', 'privy-app-id': process.env.NEXT_PUBLIC_PRIVY_APP_ID!, 'privy-authorization-signature': authorizationSignature, Authorization: `Bearer ${accessToken}` // Your app's access token }, body: JSON.stringify({ method: 'eth_sendTransaction', params: { transaction: { to: transaction.to, value: transaction.value, chain_id: transaction.chain_id, data: transaction.data, gas_limit: transaction.gas_limit, nonce: transaction.nonce, type: 2 } } })});const result = await response.json();console.log('Transaction result:', result);
Send the transaction request using the Privy Node SDK:
Report incorrect code
Copy
Ask AI
import {PrivyClient} from '@privy-io/node';const privy = new PrivyClient({appId: 'your-app-id', appSecret: 'your-app-secret'});const serverWallet = {id: 'your-wallet-id'}; // The wallet created earlierconst authorizationSignature = 'formatted-authorization-signature'; // From previous stepconst transaction = { to: '0x...', value: '1000000000000000000', // 1 ETH in wei chain_id: 1, // Ethereum mainnet data: '0x', gas_limit: '21000', nonce: 42, type: 2};// Sign the transaction using the Privy SDKconst result = await privy .wallets() .ethereum() .signMessage({ wallet_id: serverWallet.id, authorization: authorizationSignature, rpc: { method: 'eth_signTransaction', params: { transaction: { to: transaction.to, value: transaction.value, chain_id: transaction.chain_id, data: transaction.data, gas_limit: transaction.gas_limit, nonce: transaction.nonce, type: 2 } } } });console.log('Signed transaction:', result);
That’s it! Your users can now securely authorize transactions on wallets using their passkeys with WebAuthn standard authentication. 🎉
Similar to transactions, you can also sign messages using passkey authorization. Below are the steps to create a message signing request and submit it with passkey authorization:
When signing messages, it’s important to use utf-8 encoding for the message and include
chain_type: 'ethereum' in the request payload. These parameters ensure proper signature
generation and verification.
Create and format the message signing request payload
Create your message and format it into the required request payload structure:
Report incorrect code
Copy
Ask AI
import canonicalize from 'canonicalize';import {startAuthentication} from '@simplewebauthn/browser';// Your message to signconst message = 'Hello, Privy!';const serverWallet = {id: 'your-wallet-id'}; // The wallet created earlier// Format the request payload for Privy APIconst requestPayload = { version: 1, method: 'POST', url: `https://api.privy.io/v1/wallets/${serverWallet.id}/rpc`, body: { method: 'personal_sign', chain_type: 'ethereum', params: { message: message, encoding: 'utf-8' } }, headers: { 'privy-app-id': process.env.NEXT_PUBLIC_PRIVY_APP_ID! }};// Canonicalize the payload for consistent signingconst canonicalPayload = canonicalize(requestPayload) as string;// Convert to base64url as required by Privy's specificationfunction base64UrlEncode(str: string) { return btoa(str).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '');}const payloadBase64Url = base64UrlEncode(canonicalPayload);
Sign the payload with the user’s passkey
Use the WebAuthn authentication flow to sign the formatted payload:
Report incorrect code
Copy
Ask AI
import {startAuthentication} from '@simplewebauthn/browser';// Sign the payload using simpleWebAuthnconst authResponse = await startAuthentication({ optionsJSON: { challenge: payloadBase64Url, allowCredentials: [], // Empty to discover available credentials userVerification: 'preferred', rpId: 'yourdomain.com', // Must match the rpID from registration timeout: 60000 // 60 seconds }});// Extract the WebAuthn response componentsconst signature = authResponse.response.signature;const authenticatorData = authResponse.response.authenticatorData;const clientDataJSON = authResponse.response.clientDataJSON;
Format the authorization signature
Create the specially formatted authorization signature that Privy expects:
Report incorrect code
Copy
Ask AI
// Format the authorization signature for Privyconst authorizationSignature = `webauthn:${authenticatorData}:${clientDataJSON}:${signature}`;
Send the message signing request to Privy
Direct API
Node SDK
Send the message signing request with the WebAuthn authorization signature using direct API calls:
Report incorrect code
Copy
Ask AI
import fetch from 'node-fetch'; // or use global fetchconst accessToken = 'your-access-token'; // Your app's access tokenconst serverWallet = {id: 'your-wallet-id'}; // The wallet created earlierconst authorizationSignature = 'formatted-authorization-signature'; // From previous stepconst message = 'Hello, Privy!';// Send the message signing request to Privy APIconst response = await fetch(`https://api.privy.io/v1/wallets/${serverWallet.id}/rpc`, { method: 'POST', headers: { 'Content-Type': 'application/json', 'privy-app-id': process.env.NEXT_PUBLIC_PRIVY_APP_ID!, 'privy-authorization-signature': authorizationSignature, Authorization: `Bearer ${accessToken}` // Your app's access token }, body: JSON.stringify({ method: 'personal_sign', chain_type: 'ethereum', params: { message: message, encoding: 'utf-8' } })});const result = await response.json();console.log('Signature:', result.data.signature);console.log('Encoding:', result.data.encoding);
Send the message signing request using the Privy Node SDK:
Report incorrect code
Copy
Ask AI
import {PrivyClient} from '@privy-io/node';const privy = new PrivyClient({appId: 'your-app-id', appSecret: 'your-app-secret'});const serverWallet = {id: 'your-wallet-id'}; // The wallet created earlierconst authorizationSignature = 'formatted-authorization-signature'; // From previous stepconst message = 'Hello, Privy!';// Sign the message using the Privy SDKconst result = await privy .wallets() .ethereum() .signMessage({ wallet_id: serverWallet.id, authorization: authorizationSignature, rpc: { method: 'personal_sign', chain_type: 'ethereum', params: { message: message, encoding: 'utf-8' } } });console.log('Signature:', result.signature);console.log('Encoding:', result.encoding);
The Node SDK automatically includes the chain_type parameter when making RPC calls. Make sure to
account for this parameter when generating the authorization signature by including it in the
canonical payload used for the WebAuthn challenge.