Documentation Index
Fetch the complete documentation index at: https://turnkey-0e7c1f5b-ethan-captcha-protection.mintlify.app/llms.txt
Use this file to discover all available pages before exploring further.
This guide covers how to set up client-side signing using Turnkey’s @turnkey/iframe-stamper package and the export-and-sign iframe. This architecture enables secure transaction and message signing directly in the browser without exposing private keys to your application code. Note that mishandling of exported private keys introduces inherent risks; please proceed with caution.
Overview
Client-side signing allows you to:
- Export private keys from Turnkey to a secure iframe
- Sign transactions and messages directly in the browser via iframe
- Maintain multiple keys simultaneously for signing operations
- Keep private keys isolated from your application’s JavaScript context
Architecture
Security Model
- Iframe Isolation: Private keys never touch your application’s JavaScript context
- HPKE Encryption: Export bundles are encrypted end-to-end using RFC 9180
- Enclave Verification: All bundles are signed by Turnkey’s secure enclave
- Sandboxed Iframe: The iframe runs with
allow-scripts allow-same-origin sandbox restrictions
- Organization Validation: Bundles are validated against your organization ID
Prerequisites
- Node.js v20+
- A Turnkey organization with API credentials
- Wallet accounts to export (Solana addresses for signing support)
@turnkey/iframe-stamper >= 2.7.0
Installation
npm install @turnkey/iframe-stamper @turnkey/sdk-server
Environment Variables
# .env.local
NEXT_PUBLIC_ORGANIZATION_ID=<your-organization-id>
NEXT_PUBLIC_BASE_URL=https://api.turnkey.com
NEXT_PUBLIC_EXPORT_SIGN_IFRAME_URL=https://export-and-sign.turnkey.com
# Server-side only (never expose to client)
# Not necessary if user performs export using a session directly via frontend
API_PUBLIC_KEY=<your-api-public-key>
API_PRIVATE_KEY=<your-api-private-key>
Sample Implementation
Note: the following is for a NextJS application with a separate frontend and backend. The foundations should be applicable for other configurations.
Step 1: Initialize the IframeStamper
Create a component that initializes the iframe and manages its lifecycle.
import { IframeStamper } from "@turnkey/iframe-stamper";
import { useEffect, useState } from "react";
const IFRAME_CONTAINER_ID = "turnkey-iframe-container";
const IFRAME_ELEMENT_ID = "turnkey-iframe";
function SigningComponent() {
const [iframeStamper, setIframeStamper] = useState<IframeStamper | null>(
null
);
useEffect(() => {
const stamper = new IframeStamper({
iframeUrl: process.env.NEXT_PUBLIC_EXPORT_SIGN_IFRAME_URL!,
iframeContainer: document.getElementById(IFRAME_CONTAINER_ID),
iframeElementId: IFRAME_ELEMENT_ID,
});
return () => {
stamper.clear();
};
}, []);
return (
<div
id={IFRAME_CONTAINER_ID}
style={{ display: "block" }}
>
{/* Iframe will be inserted here */}
</div>
);
}
For an example in context, we highly recommend taking a look at the wallet-export-sign example app.
Step 2: Create the Export Caller (Backend or Client-side)
The export call can be made from a backend API route or a trusted client-side environment. The example below shows a server-side API route; if you call from the client, avoid exposing key material.
// pages/api/exportWalletAccount.ts
import type { NextApiRequest, NextApiResponse } from "next";
import { Turnkey } from "@turnkey/sdk-server";
export default async function handler(
req: NextApiRequest,
res: NextApiResponse
) {
const { walletAccountAddress, targetPublicKey } = req.body;
const turnkeyClient = new Turnkey({
apiBaseUrl: process.env.NEXT_PUBLIC_BASE_URL!,
apiPublicKey: process.env.API_PUBLIC_KEY!,
apiPrivateKey: process.env.API_PRIVATE_KEY!,
defaultOrganizationId: process.env.NEXT_PUBLIC_ORGANIZATION_ID!,
});
const { address, exportBundle } = await turnkeyClient
.apiClient()
.exportWalletAccount({
organizationId: process.env.NEXT_PUBLIC_ORGANIZATION_ID!,
address: walletAccountAddress,
targetPublicKey: targetPublicKey,
});
res.status(200).json({ address, exportBundle });
}
Step 3: Export a Private Key to the Iframe
import { KeyFormat } from "@turnkey/iframe-stamper";
import axios from "axios";
async function exportKeyToIframe(
iframeStamper: IframeStamper,
walletAccountAddress: string,
organizationId: string
) {
// Step 3a: Get or initialize the embedded key
let embeddedKey = await iframeStamper.getEmbeddedPublicKey();
if (!embeddedKey) {
embeddedKey = await iframeStamper.initEmbeddedKey();
}
// Step 3b: Request export bundle from your export caller
const response = await axios.post("/api/exportWalletAccount", {
walletAccountAddress,
targetPublicKey: embeddedKey,
});
// Step 3c: Inject the bundle into the iframe
const injected = await iframeStamper.injectKeyExportBundle(
response.data.exportBundle,
organizationId,
KeyFormat.Hexadecimal, // or KeyFormat.Solana for Solana-formatted keys
walletAccountAddress // Required for multi-key support
);
if (!injected) {
throw new Error("Failed to inject export bundle");
}
// The key is now stored in-memory within the iframe
// The embedded key remains available for additional exports
}
Step 4: Sign Messages
import { MessageType } from "@turnkey/iframe-stamper";
async function signMessage(
iframeStamper: IframeStamper,
message: string,
walletAccountAddress: string
): Promise<string> {
const signature = await iframeStamper.signMessage(
{
message,
type: MessageType.Solana,
},
walletAccountAddress // Required when multiple keys are loaded
);
return signature; // Returns hex-encoded signature
}
Step 5: Sign Transactions
import { TransactionType } from "@turnkey/iframe-stamper";
async function signTransaction(
iframeStamper: IframeStamper,
serializedTransaction: string, // Hex-encoded transaction bytes
walletAccountAddress: string
): Promise<string> {
const signedTransaction = await iframeStamper.signTransaction(
{
transaction: serializedTransaction,
type: TransactionType.Solana,
},
walletAccountAddress
);
return signedTransaction; // Returns hex-encoded signed transaction
}
Multi-Key Support
One of the key capabilities of client-side signing is the ability to load and manage multiple private keys simultaneously within the iframe.
Loading Multiple Keys
Since the embedded key persists across bundle injections, you can export multiple keys using the same embedded key (as long as it hasn’t expired):
async function loadMultipleKeys(
iframeStamper: IframeStamper,
addresses: string[],
organizationId: string
) {
// Get or initialize the embedded key once
let embeddedKey = await iframeStamper.getEmbeddedPublicKey();
if (!embeddedKey) {
embeddedKey = await iframeStamper.initEmbeddedKey();
}
for (const address of addresses) {
const response = await axios.post("/api/exportWalletAccount", {
walletAccountAddress: address,
targetPublicKey: embeddedKey, // Same embedded key for all exports
});
await iframeStamper.injectKeyExportBundle(
response.data.exportBundle,
organizationId,
KeyFormat.Hexadecimal,
address // Each key is stored by its address
);
}
}
Signing with Different Keys
// Sign with the first address
const sig1 = await iframeStamper.signMessage(
{ message: "Hello", type: MessageType.Solana },
"address1..."
);
// Sign with the second address
const sig2 = await iframeStamper.signMessage(
{ message: "World", type: MessageType.Solana },
"address2..."
);
Clearing Keys
// Clear a specific key
await iframeStamper.clearEmbeddedPrivateKey("address1...");
// Clear all keys (no address parameter)
await iframeStamper.clearEmbeddedPrivateKey();
Key Lifecycle and Expiration
Understanding the key lifecycle is important for building reliable applications.
Embedded Key (P-256 ECDH)
- Storage: localStorage within the iframe
- TTL: 48 hours (default)
- Purpose: Decrypt incoming export bundles via HPKE
- Behavior: Persists across bundle injections - the same embedded key can decrypt multiple export bundles until it expires or is explicitly cleared
In-Memory Private Keys
- Storage: JavaScript memory only (never persisted)
- TTL: 24 hours
- Purpose: Sign messages and transactions
- Behavior: Lost on page reload, cleared on expiration
Handling Expiration
// Re-export flow when keys expire or page reloads
async function ensureKeyLoaded(
iframeStamper: IframeStamper,
address: string,
organizationId: string
) {
try {
// Attempt to sign a test message
await iframeStamper.signMessage(
{ message: "test", type: MessageType.Solana },
address
);
} catch (error) {
// Key not found or expired - re-export
await exportKeyToIframe(iframeStamper, address, organizationId);
}
}
| Format | Description | Use Case |
|---|
KeyFormat.Solana | Base58-encoded 64-byte format (private + public) | Phantom, Solflare, Solana keys |
KeyFormat.Hexadecimal | 64 hexadecimal digits (32 bytes) | Non-Solana |
Complete Example
Here’s a complete React component demonstrating the full flow:
import {
IframeStamper,
KeyFormat,
MessageType,
TransactionType,
} from "@turnkey/iframe-stamper";
import { useEffect, useState } from "react";
import axios from "axios";
const IFRAME_CONTAINER_ID = "turnkey-iframe-container";
const IFRAME_ELEMENT_ID = "turnkey-iframe";
interface Props {
organizationId: string;
walletAccountAddress: string;
}
export function ClientSideSigner({
organizationId,
walletAccountAddress,
}: Props) {
const [iframeStamper, setIframeStamper] = useState<IframeStamper | null>(
null
);
const [isKeyLoaded, setIsKeyLoaded] = useState(false);
const [message, setMessage] = useState("Hello, Turnkey!");
const [signature, setSignature] = useState("");
// Initialize iframe
useEffect(() => {
const stamper = new IframeStamper({
iframeUrl: process.env.NEXT_PUBLIC_EXPORT_SIGN_IFRAME_URL!,
iframeContainer: document.getElementById(IFRAME_CONTAINER_ID),
iframeElementId: IFRAME_ELEMENT_ID,
});
stamper
.init()
.then(() => setIframeStamper(stamper))
.catch(console.error);
return () => stamper.clear();
}, []);
// Export key to iframe
const exportKey = async () => {
if (!iframeStamper) return;
let embeddedKey = await iframeStamper.getEmbeddedPublicKey();
if (!embeddedKey) {
embeddedKey = await iframeStamper.initEmbeddedKey();
}
const response = await axios.post("/api/exportWalletAccount", {
walletAccountAddress,
targetPublicKey: embeddedKey,
});
await iframeStamper.injectKeyExportBundle(
response.data.exportBundle,
organizationId,
KeyFormat.Hexadecimal,
walletAccountAddress
);
setIsKeyLoaded(true);
};
// Sign message
const handleSignMessage = async () => {
if (!iframeStamper || !isKeyLoaded) return;
const sig = await iframeStamper.signMessage(
{ message, type: MessageType.Solana },
walletAccountAddress
);
setSignature(sig);
};
return (
<div>
<div id={IFRAME_CONTAINER_ID} />
{!isKeyLoaded ? (
<button
onClick={exportKey}
disabled={!iframeStamper}
>
Export Key
</button>
) : (
<div>
<textarea
value={message}
onChange={(e) => setMessage(e.target.value)}
/>
<button onClick={handleSignMessage}>Sign Message</button>
{signature && <pre>Signature: {signature}</pre>}
</div>
)}
</div>
);
}
Troubleshooting
”Iframe not ready”
Ensure init() has completed before calling other methods. The iframe needs to load and establish the MessageChannel connection.
”Key not found for address”
- Verify the address is exactly as provided during
injectKeyExportBundle (case-sensitive)
- Check if the key has expired (24-hour TTL)
- Ensure the page hasn’t been reloaded (keys are in-memory only)
“Embedded key not found”
The embedded key may have expired (48-hour TTL) or been explicitly cleared. Call initEmbeddedKey() to create a new one.
”Organization ID does not match”
The bundle was created for a different organization. Ensure your backend uses the same organization ID as passed to injectKeyExportBundle.
Best Practices
- Always pass the address parameter: When using multi-key support, always specify which address to sign with
- Reuse the embedded key: The embedded key persists across bundle injections, so you can export multiple keys without re-initializing
- Handle page reloads: Implement re-export logic since in-memory keys are lost on reload (the embedded key survives in localStorage)
- Monitor key expiration: Track when keys will expire - embedded key (48h), in-memory keys (24h)
- Use appropriate key formats: Use
KeyFormat.Solana for Solana keys
Reference
IframeStamper Methods
| Method | Description |
|---|
init() | Insert iframe and establish connection |
clear() | Remove iframe and clean up resources |
getEmbeddedPublicKey() | Get current embedded key’s public key |
initEmbeddedKey() | Create new embedded key |
clearEmbeddedKey() | Clear the embedded key |
injectKeyExportBundle(bundle, orgId, format?, address?) | Inject private key into iframe |
signMessage(message, address?) | Sign a message |
signTransaction(transaction, address?) | Sign a transaction |
clearEmbeddedPrivateKey(address?) | Clear in-memory keys |
Supported Operations
| Operation | Solana | Ethereum |
|---|
| Message signing | Yes | Planned |
| Transaction signing | Yes | Planned |
Additional Resources