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.
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:
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.
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
The example above (const subOrgId = organizationIds[0];) simply takes the first ID found. This approach might not be suitable for all applications.
Crucial Verification Step: Retrieving a subOrgId via email lookup is only the first step and does not grant access. Your application must authenticate the user (e.g., via passkey) after the lookup. Only then should you verify if the authenticated user is authorized for that subOrgId, typically by checking your user database. Blindly trusting the lookup result is insecure.
Handling No Results: If organizationIds is empty, it might indicate the user doesn’t have an existing sub-organization. Your application should handle this, potentially by initiating a signup flow or failing the login.
Handling Multiple Results: If multiple IDs are returned, your application must decide how to proceed. You could:
Prompt the user to select the intended sub-organization.
Enforce a business rule that an email can only map to one sub-organization and treat multiple results as an error state.
Use other contextual information (if available) to disambiguate.
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 Cookieasync 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;
import { turnkeyClient } from "./turnkey";// This will require passkey authentication from the userconst signedWhoamiRequest = await turnkeyClient.stampGetWhoami({ organizationId: subOrgId,});
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" }); }});
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.
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 stepconst 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.
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:
Verify the JWT to get the authenticated userId.
Look up the subOrgId(s) associated with that userId in your application’s database.
Compare the subOrgId from the incoming request against the one
associated with the authenticated user in your database.
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.
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});
Backend Verification: Your backend verifies the JWT and processes the request
// Example: JWT middlewareconst 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 routeapp.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); });