Introduction

When integrating Turnkey into an application with an existing authentication system, you’ll need to establish a secure communication pattern between your frontend, backend, and the Turnkey API. This guide explains how to implement a backend proxy pattern that leverages your existing user authentication while enabling Turnkey operations.

Why Use a Backend Proxy?

There are several benefits to proxying Turnkey requests through your own backend:

  1. User data: Store and retrieve user data associated with Turnkey sub-organizations
  2. Metrics and monitoring: Add custom validations, rate limiting, and logging
  3. Co-signing capabilities: Enable 2/2 signing patterns where your application is a co-signer

JWT Authentication Flow

JSON Web Tokens (JWTs) provide a secure, stateless way to authenticate requests between your frontend and backend. Here’s how to implement a JWT-based flow with Turnkey:

Architecture Overview

Login and Sign Up Flows

The first step in integrating Turnkey with JWT authentication is handling user login and signup. Both processes follow similar patterns but differ in how they establish the user’s identity with Turnkey.

Getting the User’s Sub-organization ID

Before you can authenticate a user with Turnkey, you need their sub-organization ID. There are multiple ways to obtain this:

  1. Database Lookup: Query your database using the user’s email or ID

    const user = await db.users.findUnique({
      where: { email: userEmail },
      select: { turnkeySubOrgId: true },
    });
    
  2. Turnkey API Lookup (Alternative) If you don’t have the sub-organization ID stored alongside your user record, you can query Turnkey using the user’s email to find associated sub-organization IDs.

const { organizationIds } = await turnkeyServer.getSubOrgIds({
  organizationId: process.env.TURNKEY_ORGANIZATION_ID,
  filterType: "EMAIL",
  filterValue: userEmail,
});

// NB: This assumes the user has exactly one sub-org. See notes below!
const subOrgId = organizationIds[0]; // First matching sub-org
  1. JWT Cookie: Extract the user ID from an existing JWT in cookies (for returning users)

    If a user has previously logged in, their JWT might be stored in a cookie. You can verify this token and extract the user ID to confirm their identity.

// Example: Extracting User ID from JWT Cookie
async function getUserIdFromCookie(req) {
  const token = req.cookies.authToken;
  if (!token) return null;

  try {
    const decoded = jwt.verify(token, JWT_SECRET);
    return decoded.userId;
  } catch (error) {
    // Invalid or expired token
    return null;
  }
}

// If you need the subOrgId associated with this userId,
// perform a database lookup after verifying the JWT:
// const user = await db.users.findUnique({
//   where: { id: userId },
//   select: { turnkeySubOrgId: true }
// });
// const subOrgId = user?.turnkeySubOrgId;

Login Flow

1

Client-side

Generate a signed Turnkey request

import { turnkeyClient } from "./turnkey";

// This will require passkey authentication from the user
const signedWhoamiRequest = await turnkeyClient.stampGetWhoami({
  organizationId: subOrgId,
});
2

Send to Backend

Forward the signed request

const response = await fetch("/api/auth/login", {
  method: "POST",
  body: JSON.stringify({ signedRequest: signedWhoamiRequest }),
  headers: { "Content-Type": "application/json" },
});
3

Backend Verification

Process the request and issue JWT

import { turnkeyServer } from "./turnkey";
import jwt from "jsonwebtoken";

app.post("/api/auth/login", async (req, res) => {
  const { signedRequest } = req.body;

  try {
  // Forward to Turnkey - the request is already signed by the user's passkey
    const { url, body, stamp } = signedRequest;

    // Forward to Turnkey
    const response = await fetch(url, {
      method: 'POST',
      body,
      headers: {
        [stamp.stampHeaderName]: stamp.stampHeaderValue,
      },
    });

    const turnkeyResponse = await response.json();

    // If we get here without error, the authentication succeeded
    if (turnkeyResponse.organizationId) {
      // Lookup or create user in your database
      const user = await findOrCreateUser(turnkeyResponse.organizationId);

      // Generate JWT containing only the application's user ID
      // The subOrgId is stored in the database, associated with the userId
      const token = jwt.sign(
        {
          userId: user.id,
        },
        JWT_SECRET,
        { expiresIn: "1h" }
      );

      // Set JWT as cookie or return in response
      res.cookie("authToken", token, { httpOnly: true, secure: true });
      return res.status(200).json({ success: true });
    }
  } catch (error) {
    return res.status(401).json({ error: "Authentication failed" });
  }
});

Sign Up Flow

For new users, the flow is similar but involves creating a new sub-organization:

1

Client-side

Collect user information

import { turnkeyClient } from "./turnkey";

const email = "user@example.com";

const { encodedChallenge, attestation } =
(await turnkeyClient?.createUserPasskey({
  publicKey: {
    user: {
      name: 'Popup Wallet Demo',
      displayName: 'Popup Wallet Demo',
    },
  },
})) || {};

// Send to backend
const response = await fetch("/api/auth/signup", {
  method: "POST",
  body: JSON.stringify({
    email,
    challenge: encodedChallenge,
    attestation,
  }),
  headers: { "Content-Type": "application/json" },
});
2

Backend Processing

Create sub-organization and issue JWT

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

const JWT_SECRET = process.env.JWT_SECRET;

app.post("/api/auth/signup", async (req, res) => {
  const { email, challenge, attestation } = req.body;

  try {
    // Create the sub-organization
    const createSubOrgResponse = await turnkeyServer.createSubOrganization({
      subOrganizationName: `${email} - Organization`,
      rootUsers: [
        {
          userName: email,
          userEmail: email,
          apiKeys: [],
          authenticators: [
            {
              authenticatorName: "Default Passkey",
              challenge,
              attestation,
            },
          ],
        },
      ],
      rootQuorumThreshold: 1,
      wallet: {
        walletName: "Default Wallet",
        accounts: DEFAULT_ETHEREUM_ACCOUNTS,
      },
    });

    if (createSubOrgResponse.organizationId) {
      // Store user in your database
      const user = await createUser(createSubOrgResponse.organizationId, email);

      // Generate JWT containing only the application's user ID
      const token = jwt.sign(
        {
          userId: user.id,
        },
        JWT_SECRET,
        { expiresIn: "1h" }
      );

      res.cookie("authToken", token, { httpOnly: true, secure: true });
      return res.status(200).json({
        success: true,
        message: "Signup successful, user created, and logged in."
      });
    }
  } catch (error) {
    // Handle specific errors (e.g., email already exists)
    console.error("Signup Error:", error);
    return res.status(500).json({ error: "Signup failed" });
  }
});

Email OTP Authentication Flow

Turnkey allows users to authenticate via email using a secure One-Time Passcode (OTP). Your backend initiates this by calling sendOtp. Turnkey emails the code to the user. The user enters the code in the frontend, which sends it (along with identifiers) to your backend. Your backend then calls verifyOtp. Upon successful Turnkey verification, your backend issues its own application JWT to manage the proxied session.

Note: This flow generally assumes the user and their associated Turnkey Sub-Organization already exist, as it’s primarily for authentication rather than initial signup.

Architecture Overview

Implementation Steps

1

Request OTP

The user provides their email. The frontend gets the Turnkey iframe’s public key (referred to as targetPublicKey) and sends both to the backend. The backend finds the user’s sub-organization and asks Turnkey to send the OTP email by calling sendOtp, passing the iframe’s public key as the targetPublicKey parameter (used by Turnkey for credential encryption upon successful verification).

import { useTurnkey } from "@turnkey/sdk-react"; // Or however you access the client

// User enters email and clicks 'Send OTP'
const handleRequestOtp = async (email: string) => {
  const { authIframeClient } = useTurnkey();
  if (!authIframeClient) {
    console.error("Turnkey iframe client not available");
    // Handle error: Inform user or disable button
    return;
  }

  try {
    // Get the public key associated with the Turnkey iframe client instance.
    // This is sent to the backend for the sendOtp request.
    const targetPublicKey = await authIframeClient.getPublicKey();

    const response = await fetch("/api/auth/otp/request", {
      method: "POST",
      headers: { "Content-Type": "application/json" },
      body: JSON.stringify({ email, targetPublicKey }),
    });

    const data = await response.json();

    if (response.ok) {
      // Store otpId returned from backend for the verification step
      const otpId = data.otpId;
      console.log("OTP requested successfully, otpId:", otpId);
      // Show UI for OTP input, store otpId for the next step
      // e.g., setOtpIdState(otpId);
    } else {
      console.error("Failed to request OTP:", data.error);
      // Show error message to the user
    }
  } catch (error) {
    console.error("Error requesting OTP:", error);
    // Show error message to the user
  }
};
2

Verify OTP & Issue Backend JWT

The user submits the received OTP code. The frontend gets the iframe’s public key again (as targetPublicKey for encryption) and sends the code, email, original otpId, and targetPublicKey to the backend. The backend asks Turnkey to verify the code using verifyOtp. If successful, the backend generates and sets its own application session JWT cookie and returns the authSession object from Turnkey to the frontend. The frontend then uses this authSession to establish the Turnkey session within the iframe via loginWithSession.

import { useTurnkey } from "@turnkey/sdk-react";

// User enters OTP and clicks 'Verify'
// Assumes 'otpId' was stored in state from the previous step
const handleVerifyOtp = async (
  email: string,
  otpCode: string,
  otpId: string
) => {
  const { authIframeClient } = useTurnkey();
  if (!authIframeClient) {
    console.error("Turnkey iframe client not available");
    return;
  }

  try {
    // Get the public key again, this time as the target for credential encryption
    const targetPublicKey = await authIframeClient.getPublicKey();

    const response = await fetch("/api/auth/otp/verify", {
      method: "POST",
      headers: { "Content-Type": "application/json" },
      body: JSON.stringify({ email, otpCode, otpId, targetPublicKey }),
    });

    if (response.ok) {
      // Backend successfully verified OTP with Turnkey and issued its JWT (in cookie)
      console.log("OTP verification successful, backend session established.");

      // Get the authSession from the backend response
      const data = await response.json();
      const authSession = data.authSession;

      if (!authSession?.token) {
        console.error("Backend did not return a valid Turnkey authSession.");
        // Handle error: inform user authentication might be incomplete
        return;
      }

      // Use the session token from the backend to log in the Turnkey iframe
      console.log("Establishing Turnkey iframe session...");
      await authIframeClient.loginWithSession(authSession);
      console.log("Turnkey iframe session established.");

      // Now both backend app session (cookie) and Turnkey iframe session are active.
      // Redirect user or update UI state (e.g., router.push('/dashboard'))
    } else {
      const data = await response.json();
      console.error("OTP verification failed:", data.error);
      // Show error message to the user
    }
  } catch (error) {
    console.error("Error verifying OTP:", error);
    // Show error message to the user
  }
};

This provides a secure authentication flow using Turnkey’s Email OTP service, integrated with your backend’s JWT-based session management and Turnkey’s frontend iframe session management.

Subsequent Authenticated Requests

Once the user is logged in (via passkey, OTP, or other methods) and has a valid JWT, subsequent requests from the frontend to your backend should include this JWT.

Crucial Security Note on Proxied Requests (Read and Write):

When proxying requests to Turnkey through your backend, it’s critical to ensure that the authenticated user (identified by userId from the JWT) is actually authorized to perform the requested action on the target Turnkey sub-organization (subOrgId). This applies to both write operations (like signing transactions) and read operations (like fetching balances or activities).

Simply verifying the JWT authenticates the user, but it doesn’t authorize them for a specific sub-organization. Your backend must:

  1. Verify the JWT to get the authenticated userId.
  2. Look up the subOrgId(s) associated with that userId in your application’s database.
  3. Compare the subOrgId from the incoming request against the one associated with the authenticated user in your database.
  4. Only proceed if the requested subOrgId matches the one(s) associated with the authenticated user.

Failure to perform this check, especially on read operations, could allow a user to potentially access information from sub-organizations they do not belong to.

  1. Client-side: Include JWT in requests to your backend

    const signedTurnkeyRequest = await turnkeyClient.stampSignRequest({
      organizationId: subOrgId,
      signWith: "private-key-id", // ID of the private key to use for signing
      type: "TRANSACTION_TYPE_ETHEREUM", // Type of transaction
      unsignedTransaction: "0x...", // Hex-encoded unsigned transaction
    });
    
    const response = await fetch("/api/proxy/turnkey/sign-transaction", {
      method: "POST",
      body: JSON.stringify({ signedTurnkeyRequest }),
      headers: {
        "Content-Type": "application/json",
        // JWT automatically included in cookies if httpOnly
        // Or explicitly: 'Authorization': `Bearer ${jwt}`
      },
      credentials: "include", // Include cookies
    });
    
  2. Backend Verification: Your backend verifies the JWT and processes the request

    // Example: JWT middleware
    const verifyJwt = (req, res, next) => {
      const token =
        req.cookies.authToken || req.headers.authorization?.split(" ")[1];
    
      if (!token) {
        return res.status(401).json({ error: "Unauthorized" });
      }
    
      try {
        const decoded = jwt.verify(token, JWT_SECRET);
        // Attach only userId to the request object
        req.user = { userId: decoded.userId };
        next();
      } catch (err) {
        return res.status(401).json({ error: "Invalid token" });
      }
    };
    
    // Protected route
    app.post(
      "/api/proxy/turnkey/sign-transaction",
      verifyJwt,
      async (req, res) => {
        const { signedRequest } = req.body;
        // Extract userId from the request object populated by middleware
        const { userId } = req.user;
    
        // --- Database Lookup Required for Authorization ---
        // Fetch the user's subOrgId from your database.
        // This lookup is ESSENTIAL for authorizing the user for the target sub-org,
        // regardless of whether the operation is a read or a write.
        const user = await db.users.findUnique({
          where: { id: userId },
          select: { turnkeySubOrgId: true },
        });
        const subOrgId = user?.turnkeySubOrgId;
    
        if (!subOrgId) {
          // User exists (JWT verified) but isn't linked to a sub-org in our DB
          return res.status(403).json({
            error:
              "Forbidden: User not associated with a Turnkey sub-organization.",
          });
        }
        // --- End DB Lookup ---
    
        // Validate the request is for the correct sub-organization
        // Compare the subOrgId from the incoming request against the one
        // associated with the authenticated user in our database.
        if (signedRequest.organizationId !== subOrgId) {
          return res
            .status(403)
            .json({ error: "Forbidden: Sub-organization ID mismatch" });
        }
    
        // Forward to Turnkey
        const { url, body, stamp } = signedRequest;
    
        const response = await fetch(url, {
          method: "POST",
          body,
          headers: {
            [stamp.stampHeaderName]: stamp.stampHeaderValue,
          },
        });
    
        const turnkeyResponse = await response.json();
    
        // Return the response
        return res.status(200).json(turnkeyResponse);
      }
    );
    

JWT Implementation

When implementing JWT authentication:

import jwt from "jsonwebtoken";

// Get this from a secure environment variable
const JWT_SECRET = process.env.JWT_SECRET;

// Generate a JWT after successful authentication
function generateToken(user) {
  return jwt.sign(
    {
      userId: user.id,
    },
    JWT_SECRET,
    {
      expiresIn: "1h", // Token expires in 1 hour
    }
  );
}

// Verify a JWT
function verifyToken(token) {
  try {
    return jwt.verify(token, JWT_SECRET);
  } catch (error) {
    // Handle various JWT verification errors
    return null;
  }
}

JWT Best Practices

  • Keep token expiration times short (15-60 minutes)
  • Include only necessary claims in the payload
  • Use HTTPS for all communications
  • Store tokens securely (use HTTP-only cookies rather than localStorage)
  • Implement token refresh mechanisms for long-lived sessions
  • Consider using JWTs with signatures (JWS) for enhanced security

User Data Storage and Retrieval

Many applications need to store additional user data and associate it with their Turnkey activities. Here’s how to implement this:

User-Turnkey Association Model

Store a mapping between your application’s user IDs and their corresponding Turnkey organization IDs:

// Prisma schema example
model User {
  id                String   @id @default(uuid())
  email             String   @unique
  name              String?
  turnkeySubOrgId   String   @unique
  createdAt         DateTime @default(now())
  lastLoginAt       DateTime @updatedAt
}

Storing User Data

When a user first authenticates with Turnkey (either by creating a new sub-organization or linking to an existing one):

  1. Save the Turnkey organization ID in your user database
  2. Optionally store additional metadata (creation time, recovery options, etc.)
  3. Set up any necessary hooks or listeners for Turnkey activities

Retrieving User Data

When processing a proxied Turnkey request:

  1. Extract the user ID from the authenticated JWT
  2. Look up the associated Turnkey organization ID
  3. Use this organization ID when forwarding requests to Turnkey

Implementation Examples

Next.js Server Actions

Next.js Server Actions provide a convenient way to implement secure backend operations:

"use server";

import { cookies } from "next/headers";
import jwt from "jsonwebtoken";
import { db } from "@/lib/db"; // Assuming db setup
import { turnkeyServer } from "./turnkey"; // Import the initialized client

// Proxy a signing request to Turnkey
export async function proxySignTransaction(signedRequest) {
  const token = cookies().get("authToken")?.value;

  if (!token) {
    throw new Error("Unauthorized: No token provided");
  }

  let userId;
  try {
    const decoded = jwt.verify(token, process.env.JWT_SECRET);
    userId = decoded.userId;
  } catch (err) {
    throw new Error("Unauthorized: Invalid token");
  }

  // --- Database Lookup Required for Authorization ---
  const user = await db.user.findUnique({
    where: { id: userId },
    select: { turnkeySubOrgId: true },
  });
  const subOrgId = user?.turnkeySubOrgId;

  if (!subOrgId) {
    throw new Error(
      "Forbidden: User not associated with a Turnkey sub-organization."
    );
  }
  // --- End DB Lookup ---

  // Validate the request is for the correct sub-organization
  if (signedRequest.organizationId !== subOrgId) {
    throw new Error("Forbidden: Sub-organization ID mismatch");
  }

  try {
    // Forward to Turnkey
    const { url, body, stamp } = signedRequest;

    const response = await fetch(url, {
      method: "POST",
      body,
      headers: {
        [stamp.stampHeaderName]: stamp.stampHeaderValue,
      },
    });

    const turnkeyResponse = await response.json();
    return turnkeyResponse;
  } catch (error) {
    console.error("Error proxying to Turnkey:", error);
    throw new Error("Failed to proxy request to Turnkey");
  }
}

Express.js Example

For standard Node.js applications using Express:

import express from "express";
import cookieParser from "cookie-parser";
import jwt from "jsonwebtoken";
import { Turnkey } from "@turnkey/sdk-server";

const app = express();
app.use(express.json());
app.use(cookieParser());

// Initialize the server-side Turnkey client
const turnkeyServer = new Turnkey({
  apiBaseUrl: "https://api.turnkey.com",
  apiPrivateKey: process.env.TURNKEY_API_PRIVATE_KEY,
  apiPublicKey: process.env.TURNKEY_API_PUBLIC_KEY,
  defaultOrganizationId: process.env.TURNKEY_ORGANIZATION_ID,
}).apiClient();

// JWT middleware
const verifyJwt = (req, res, next) => {
  const token =
    req.cookies.authToken || req.headers.authorization?.split(" ")[1];

  if (!token) {
    return res.status(401).json({ error: "Unauthorized" });
  }

  try {
    const decoded = jwt.verify(token, process.env.JWT_SECRET);
    // Attach only userId to the request object
    req.user = { userId: decoded.userId };
    next();
  } catch (err) {
    return res.status(401).json({ error: "Invalid token" });
  }
};

// Proxy endpoint for Turnkey transaction signing
app.post("/api/proxy/turnkey/sign-transaction", verifyJwt, async (req, res) => {
  const { signedRequest } = req.body;
  // Extract userId from the request object populated by middleware
  const { userId } = req.user;

  // --- Database Lookup Required for Authorization ---
  // Fetch the user's subOrgId from your database
  const user = await db.users.findUnique({
    where: { id: userId },
    select: { turnkeySubOrgId: true },
  });
  const subOrgId = user?.turnkeySubOrgId;

  if (!subOrgId) {
    // User exists (JWT verified) but isn't linked to a sub-org in our DB
    return res.status(403).json({
      error: "Forbidden: User not associated with a Turnkey sub-organization.",
    });
  }
  // --- End DB Lookup ---

  // Validate the request is for the correct sub-organization
  // Compare against the subOrgId retrieved from the database
  if (signedRequest.organizationId !== subOrgId) {
    return res
      .status(403)
      .json({ error: "Forbidden: Sub-organization ID mismatch" });
  }

  try {
    // Forward to Turnkey
    const { url, body, stamp } = signedRequest;

    const response = await fetch(url, {
      method: "POST",
      body,
      headers: {
        [stamp.stampHeaderName]: stamp.stampHeaderValue,
      },
    });

    const turnkeyResponse = await response.json();

    // Return the response
    return res.status(200).json(turnkeyResponse);
  } catch (error) {
    console.error("Error signing transaction:", error);
    return res.status(500).json({
      error: "Failed to sign transaction",
      message: error.message,
    });
  }
});

const PORT = process.env.PORT || 3000;
app.listen(PORT, () => {
  console.log(`Server running on port ${PORT}`);
});

Security Considerations

When implementing this pattern, keep these security considerations in mind:

  1. API Key Security: Never expose your backend Turnkey API keys to the frontend
  2. JWT Validation: Thoroughly validate JWTs (signature, expiration, audience, etc.)
  3. Input Validation: Sanitize and validate all input received from the frontend
  4. Rate Limiting: Implement rate limiting to prevent abuse
  5. Monitoring: Set up logging and monitoring for suspicious activities
  6. Error Handling: Return appropriate error messages without exposing sensitive details

Advanced Topics

Co-signing Transactions

Learn about advanced patterns like multi-signature setups requiring approvals from both the user and the backend.