Skip to main content

Documentation Index

Fetch the complete documentation index at: https://docs.turnkey.com/llms.txt

Use this file to discover all available pages before exploring further.

Some teams have requirements that make it impractical to use Turnkey’s built-in authentication directly — existing enterprise SSO, compliance constraints, a mature identity platform already in production, or simply a preference to keep auth centralized in one system. In these cases, you don’t need to replace your auth layer: Turnkey can work alongside it. If you already have an authentication system — whether that’s Auth0, Cognito, an enterprise IdP, or a homegrown JWT issuer — you can keep using it to authenticate users and only rely on Turnkey for wallet and key management. The key decision is whether you want that integration to be custodial or non-custodial.

Custodial vs. non-custodial

Custodial (API key approach)

The simplest integration pattern is to use a parent org API key on your backend. Your server authenticates users however it normally would, and then uses the API key to operate on their Turnkey sub-organization on their behalf. This works well and is easy to implement, but it is custodial: your backend holds signing authority over user wallets. Sub-org provisioning: Your backend generates an API key pair for the user and registers it in the sub-org at creation time. This API key lives in the sub-org and is what your backend uses to stamp all subsequent requests on that user’s behalf.
await client.createSubOrganization({
  subOrganizationName: `user-${userId}`,
  rootQuorumThreshold: 1,
  rootUsers: [{
    userName: userEmail,
    userEmail: userEmail,
    apiKeys: [{
      apiKeyName: "backend-key",
      publicKey: YOUR_BACKEND_PUBLIC_KEY,
    }],
    authenticators: [],
    oauthProviders: [],
  }],
});

Non-custodial (OIDC approach)

If you want users to fully control their wallets, Turnkey must be able to independently verify that a user has authenticated with your system. This requires your auth system to issue OIDC-compliant tokens, a standard Turnkey knows how to validate. With that in place, the user’s device generates a keypair, receives an OIDC token from your auth system that binds that keypair to their identity, and Turnkey registers the public key as a session credential directly in the sub-org. Sub-org provisioning: Your backend still creates the sub-org (using the parent org API key) on first registration, but includes the user’s OIDC provider in the root user so that the user can authenticate directly with Turnkey on subsequent logins.
// Backend creates the sub-org with the OIDC provider registered
await client.createSubOrganization({
  subOrganizationName: `user-${userId}`,
  rootQuorumThreshold: 1,
  rootUsers: [{
    userName: userEmail,
    userEmail: userEmail,
    apiKeys: [],
    authenticators: [],
    oauthProviders: [{
      providerName: "my-auth-system",
      oidcToken: idToken,
    }],
  }],
});
After provisioning, each login follows these steps:
  1. Client generates a P256 keypair and computes nonce = sha256(publicKey)
  2. Client passes the nonce to your auth system when requesting a token
  3. Auth system issues an OIDC token with that nonce embedded
  4. Client sends the OIDC token and publicKey to your backend
  5. Backend calls oauthLogin (stamped with the parent org API key) with the OIDC token and publicKey
  6. Turnkey verifies the token signature and checks that nonce == sha256(publicKey), then returns a session JWT
  7. Client stores the session — only the device holding the private key can use it to sign

Requirements for your OIDC issuer

To use the non-custodial flow, your auth system must issue OIDC-compliant tokens with standard claims (iss, sub, aud, exp), a publicly reachable /.well-known/openid-configuration endpoint, and a nonce set to sha256(publicKey) to bind the token to the user’s device keypair. See OIDC token verification for the full details on how Turnkey validates tokens.

Integration flow

Step 1: Register the user

When a user first authenticates, create a Turnkey sub-org for them with your OIDC provider registered. Registration requires a valid OIDC token — Turnkey verifies its signature against your JWKS and extracts the iss, sub, and aud claims, storing them as the user’s identity fingerprint. The token itself is not retained; on each subsequent login a fresh token is verified independently and matched against that fingerprint. See Registration vs. login tokens for the full explanation.
await client.createSubOrganization({
  subOrganizationName: `user-${userId}`,
  rootQuorumThreshold: 1,
  rootUsers: [{
    userName: userEmail,
    userEmail: userEmail,
    apiKeys: [],
    authenticators: [],
    oauthProviders: [{
      providerName: "my-auth-system",
      oidcToken: idToken,
    }],
  }],
});

Step 2: Log the user in

On each login, the frontend generates a keypair, requests a token with nonce = sha256(publicKey) from your auth server, then calls oauthLogin:
// --- Client ---
// 1. Generate session keypair on the user's device
const publicKey = await createApiKeyPair();

// 2. Compute nonce and trigger auth on your auth server
//    Your server must embed nonce = sha256(publicKey) in the issued OIDC token
const nonce = sha256(publicKey);
const idToken = await yourAuthServer.issueToken({ userId, nonce });

// 3. Send publicKey + idToken to your backend to complete the Turnkey login
const session = await yourBackend.login({ idToken, publicKey });

// 4. Store the session — from here the device's private key is the only signer
await storeSession({ sessionToken: session });
// --- Backend (server action / API route) ---
// Stamped with the parent org API key; calls Turnkey on behalf of the user
export async function login({ idToken, publicKey, suborgId }) {
  const { session } = await turnkeyClient.oauthLogin({
    organizationId: suborgId,
    oidcToken: idToken,
    publicKey,
  });
  return session;
}
Turnkey’s enclave verifies the token signature against your JWKS and checks the nonce matches sha256(publicKey). The resulting session JWT is scoped to that public key — only the device holding the private key can use it to sign. If you are using @turnkey/react-wallet-kit, see Advanced backend authentication for how to wire this up on the frontend. For a working implementation of this flow, see the oauth example in the SDK — it uses Google as the provider, but the client/backend split and nonce binding pattern are identical for any OIDC issuer.

Adding an OIDC provider to an existing user

If a user already has a Turnkey sub-org (created via email OTP, passkey, etc.) and you want to add your OIDC provider to their account, use createOauthProviders. This activity must be stamped with a credential that already has authority in that sub-org — for example, the user’s active session, their passkey, or a backend API key registered in the sub-org. The parent org API key alone cannot stamp activities against a sub-org.
// Stamped with a credential that has authority in the sub-org
await client.createOauthProviders({
  userId: existingUserId,
  oauthProviders: [{
    providerName: "my-auth-system",
    oidcToken: idToken,
  }],
});