Skip to main content
Before diving in, here’s what this setup accomplishes: You’ll create a sub-organization where the end-user has full control (root user) and a Delegated Access (DA) user (managed by your backend) can sign only specific transactions, for example, sending to approved recipient addresses. This ensures your backend can perform limited, policy-controlled actions on behalf of the user without ever holding their root privileges. The entire setup is initiated and approved by the end-user through their authenticated session, ensuring all actions occur under their explicit control within their sub-organization. A working example of client-side delegated access and policy validation can be found here.

Step-by-step implementation

You can configure delegated access entirely on the client side using the Turnkey SDKs — @turnkey/react-wallet-kit for React apps, or @turnkey/core for other frontend frameworks. This works whether you use the Auth Proxy or your own backend to provision the sub-organization and issue the user’s authenticated session. Both approaches follow the same principle — using an authenticated session to add a Delegated Access (DA) user and defining policies for that user within the sub-organization.

Step 1: Create an embedded wallet

In this guide we’ll be using @turnkey/react-wallet-kit together with Auth Proxy.
  • Customize sub-organization creation process in your React application by defining the following TurnkeyProvider configuration. This will add a new Ethereum wallet account alongside the sub-organization creation.
proviers.tsx
export function Providers({ children }: { children: React.ReactNode }) {
  const router = useRouter();

  const suborgParams = useMemo<CreateSubOrgParams>(() => {
    const ts = Date.now();
    return {
      userName: `User-${ts}`,
      customWallet: {
        walletName: `Wallet-${ts}`,
        walletAccounts: [
          {
            curve: "CURVE_SECP256K1",
            pathFormat: "PATH_FORMAT_BIP32",
            path: "m/44'/60'/0'/0/0",
            addressFormat: "ADDRESS_FORMAT_ETHEREUM",
          },
        ],
      },
    };
  }, []);

  const turnkeyConfig: TurnkeyProviderConfig = {
    organizationId: process.env.NEXT_PUBLIC_ORGANIZATION_ID!,
    authProxyConfigId: process.env.NEXT_PUBLIC_AUTH_PROXY_CONFIG_ID!,
    auth: {
      createSuborgParams: {
        emailOtpAuth: suborgParams,
        smsOtpAuth: suborgParams,
        walletAuth: suborgParams,
        oauth: suborgParams,
        passkeyAuth: {
          ...suborgParams,
          passkeyName: "My Passkey",
        },
      },
    },
  };
  • Handle authentication is using the handleLogin function from the useTurnkey hook. It’s idempotent — existing users simply log in to their sub-org.
page.tsx
"use client";
import { useTurnkey } from "@turnkey/react-wallet-kit";

function LoginButton() {
    const { handleLogin } = useTurnkey();
    return <button onClick={handleLogin}>Login / Sign Up</button>;
}
export default function Home() {
    return (
        <LoginButton /> 
  );
}

Step 2: Create P-256 API key user

Next, use the authenticated end-user session to create a P-256 API key user whose key is managed by your backend for delegated actions. This will become the Delegated Access (DA) user once you define policies that grant it limited signing permissions. You can create this user at any point, either immediately after login or on-demand when an action (such as placing a limit order) requires delegated access. Note: This function is idempotent — calling it multiple times with the same publicKey will always return the same user rather than creating a new one.
src/dashboard/page.tsx
const handleDaSetup = async () => {
    if (!isHexCompressedPubKey(daPublicKey)) {
      setPublicKeyErr(
        "Public key must be a 66-hex-character compressed key (no 0x prefix).",
      );
      return;
    }
    setPublicKeyErr(null);

    try {
      const res = await fetchOrCreateP256ApiKeyUser({
        publicKey: daPublicKey,
        createParams: {
          userName: "Delegated Access",
          apiKeyName: "Delegated User API Key",
        },
      });
      setDaUser(res);
    } catch (err) {
      console.error("Error setting up DA user:", err);
      setDaUser({ error: "Failed to set up DA user." });
    }
};
Note: At this stage, the DA user is non-root, so any signing attempts will be denied by the policy engine because:
  • the user is not part of the root quorum, and
  • no policy has been added yet to allow that action.

Step 3: Add a restrictive policy

Now that you’ve created the P-256 API key user, you can define policies that turn it into a Delegated Access (DA) user) — granting it limited permissions to sign specific transactions. In this example, we’ll allow the DA user to sign Ethereum transactions only to a given recipient address.
src/dashboard/page.tsx
const handleBuildPolicyTemplate = () => {
    if (!daUser?.userId) {
      setPolicyError("Set up the Delegated Access user first.");
      return;
    }
    if (!isEthAddress(recipientAddress)) {
      setRecipientErr("Enter a valid 0x-prefixed, 40-hex Ethereum address.");
      return;
    }
    setRecipientErr(null);
    setPolicyError(null);

    const template = [
      {
        policyName: `Allow user ${daUser.userId} to sign only to ${recipientAddress}`,
        effect: "EFFECT_ALLOW",
        consensus: `approvers.any(user, user.id == '${daUser.userId}')`,
        condition: `eth.tx.to == '${recipientAddress}'`,
        notes:
          "Allow Delegated Access user to sign Ethereum transactions only to the specified recipient",
      },
    ];
    setPolicyJson(JSON.stringify(template, null, 2));
  };

  // Keep in sync setToAllowed if recipientAddress changes manually
  useEffect(() => {
    if (recipientAddress) {
      setToAllowed(recipientAddress);
    }
  }, [recipientAddress]);

  const handleSubmitPolicies = async () => {
    setPolicyError(null);
    setPolicyResult(null);
    setSubmittingPolicy(true);
    try {
      const parsed = JSON.parse(policyJson);
      if (!Array.isArray(parsed)) {
        throw new Error("JSON must be an array of policy objects.");
      }
      const res = await fetchOrCreatePolicies({ policies: parsed });
      setPolicyResult(res);
    } catch (e: any) {
      setPolicyError(e?.message || "Failed to submit policies.");
    } finally {
      setSubmittingPolicy(false);
    }
  };
Note:
  • Reminder, until this policy is added, the DA user cannot sign any transactions — it’s a non-root user with no permissions by default.
  • The fetchOrCreatePolicies method compares the full intent signature of each policy you pass in:
    • If a policy already exists with the exact same fields, it will be reused.
    • If any field differs, even something as small as the policy name or the notes text, it will be treated as a new policy and created again.
That’s all ! At this point, your DA user is configured with an API key and governed by a restrictive policy. You can now validate by attempting two signatures: one that matches the allowed recipient (success) and one with a different recipient (denied).
I