Introduction to Co-signing

Co-signing, often referred to as multi-signature (multi-sig), provides an enhanced layer of security for blockchain transactions. It requires approvals from multiple parties before a transaction can be executed. This guide details how to implement a 2/2 co-signing setup using Turnkey, where both the end-user (via passkey) and your application backend (via API key) must approve transactions.

Co-signing Architecture

The following diagram illustrates the setup and transaction flow for a co-signing wallet managed by Turnkey and your backend application:

Implementation Steps

1

Create a Sub-Organization with Multiple Root Users

To set up a multi-sig wallet in Turnkey, you first need to create a sub-organization with two root users. This sub-organization will function as a separate entity with its own wallet and security settings.

The key configuration here is setting up:

  • A root user for the end-user, authenticated with their passkey
  • A root user for your application service, authenticated with an API key
  • A root quorum threshold of 2, requiring both users to approve critical operations

This creates a true multi-sig arrangement where neither party can unilaterally control the wallet. The following code shows how to implement this setup on your backend:

import { turnkeyServer } from "./turnkey";
import { DEFAULT_ETHEREUM_ACCOUNTS } from "@turnkey/sdk-browser";

async function createMultiSigWallet(
  userId,
  userEmail,
  userPasskeyChallenge,
  userPasskeyAttestation
) {
  // Create a sub-organization with two root users and a threshold of 2
  const subOrg = await turnkeyServer.createSubOrganization({
    organizationId: process.env.TURNKEY_ORGANIZATION_ID,
    subOrganizationName: `Multi-Sig Wallet for ${userEmail}`,
    rootUsers: [
      // First root user - the end user with their passkey
      {
        userName: "End User",
        userEmail: userEmail,
        apiKeys: [],
        authenticators: [
          {
            authenticatorName: "User Passkey",
            challenge: userPasskeyChallenge,
            attestation: userPasskeyAttestation,
          },
        ],
      },
      // Second root user - your application's service account
      {
        userName: "Application Service",
        userEmail: "service@yourapp.com",
        apiKeys: [
          {
            apiKeyName: "Service API Key",
            publicKey: process.env.SERVICE_API_PUBLIC_KEY,
            curveType: "API_KEY_CURVE_SECP256K1",
          },
        ],
        authenticators: [],
      },
    ],
    // This is the key setting - requiring both users to approve
    rootQuorumThreshold: 2,
    wallet: {
      walletName: "Shared Wallet",
      accounts: DEFAULT_ETHEREUM_ACCOUNTS,
    },
  });

  // Save the sub-organization ID in your database
  await db.users.update({
    where: { id: userId },
    data: { turnkeySubOrgId: subOrg.organizationId },
  });

  return subOrg;
}
2

Client-Side Transaction Initiation

When the user wants to sign a transaction using their multi-sig wallet, they need to initiate the process from your frontend application. This step involves:

  • Authenticating the user with their passkey (handled automatically by Turnkey)
  • Creating a transaction signing request to Turnkey
  • Receiving an activity fingerprint that needs further approval
  • Forwarding this fingerprint to your backend for the second signature

The transaction won’t be fully signed yet - it will be in a “requires consensus” state until your backend approves it. Here’s how to implement this flow in your frontend:

import { turnkeyPasskeyClient } from "./turnkey";


// User initiates a transaction signing
async function initiateTransaction(txData, walletAddress) {
  // This will trigger the user's passkey authentication
  const signActivity = await turnkeyPasskeyClient.signTransaction({
    organizationId: subOrgId,
    parameters: {
      type: "TRANSACTION_TYPE_ETHEREUM",
      signWith: walletAddress,
      unsignedTransaction: txData,
    },
  });

  // The activity needs approval from the backend service
  // Send the activity fingerprint to your backend
  const response = await fetch("/api/proxy/turnkey/approve-transaction", {
    method: "POST",
    body: JSON.stringify({
      activityFingerprint: signActivity.fingerprint,
      subOrgId: subOrgId,
    }),
    headers: {
      "Content-Type": "application/json",
    },
    credentials: "include", // Include cookies for JWT authentication
  });

  const result = await response.json();
  return result;
}
3

Backend Activity Approval

Your backend needs an endpoint to receive the activity fingerprint from the frontend and approve it using its own API key.

import { turnkeyServer } from "./turnkey";
import { verifyJwt } from "./authMiddleware"; // Assume standard JWT middleware

// Endpoint to approve a transaction activity
app.post(
  "/api/proxy/turnkey/approve-transaction",
  verifyJwt,
  async (req, res) => {
    const { activityFingerprint, subOrgId } = req.body;
    const { userId } = req.user;

    // --- Authorization Check ---
    // Verify the user is authorized for this subOrgId
    const user = await db.users.findUnique({
      where: { id: userId },
      select: { turnkeySubOrgId: true },
    });

    if (user?.turnkeySubOrgId !== subOrgId) {
      return res.status(403).json({ error: "Forbidden" });
    }
    // --- End Authorization ---

    try {
      // TODO: Validate the transaction details from the activity
      // Fetch the activity from Turnkey to inspect its contents
      // const activity = await turnkeyServer.getActivity({
      //   activityId: activityId, // Need activityId from fingerprint?
      //   organizationId: subOrgId
      // });
      // if (activity.details.isInvalid) { throw new Error(...) }
      
      // Approve the activity using the backend service's API key
      const approvalActivity = await turnkeyServer.approveActivity({
        organizationId: subOrgId,
        fingerprint: activityFingerprint,
      });

      // Once approved, the transaction is signed
      // You might want to poll getActivity or use Webhooks to get the signed tx
      // For simplicity, we assume success here
      return res.status(200).json({ success: true, message: "Transaction approved by backend" });
    } catch (error) {
      console.error("Error approving transaction:", error);
      return res.status(500).json({ error: "Failed to approve transaction" });
    }
  }
);

Security Considerations and Best Practices

  • Validation Before Approval: Always validate transaction details (recipient, amount, etc.) before approving activities.
  • API Key Security: Protect your backend service’s API key.
  • Authorization: Ensure the authenticated frontend user is authorized for the subOrgId they are interacting with.
  • Webhooks: Use Turnkey Webhooks to get notified about activity status changes (e.g., when a transaction is fully signed and confirmed).