LangSmith Multi-Tenant: Separate Projects and API Keys

Configure LangSmith for multi-tenant apps by isolating projects, rotating API keys per tenant, and preventing trace data leakage between clients.

Problem: All Your Tenants' Traces Are Mixing in One Project

You ship a LangChain-powered SaaS. Customer A's traces appear next to Customer B's. One leaked API key exposes every tenant's data. Your LangSmith dashboard is a single-project dumping ground.

This is the default LangSmith setup — and it's wrong for production multi-tenant apps.

You'll learn:

  • How to provision separate LangSmith projects per tenant programmatically
  • How to scope API keys so each tenant only writes to their own project
  • How to route traces at runtime without restarting your app

Time: 25 min | Difficulty: Advanced


Why the Default Setup Breaks Multi-Tenancy

LangSmith uses three concepts that matter here:

  • Workspace — your top-level org (one per LangSmith account or self-hosted instance)
  • Project — a named bucket of traces inside a workspace (LANGCHAIN_PROJECT env var)
  • API key — authenticates writes; scoped to the workspace, not to a project

The problem: API keys are workspace-scoped by default. Any key can write to any project. If you give tenants direct LangSmith access, or if your backend leaks a key, isolation breaks immediately.

The fix is a two-layer approach:

  1. One LangSmith project per tenant (logical isolation)
  2. One API key per tenant (access isolation), rotated independently

Solution

Step 1: Create a Project Per Tenant via the LangSmith API

LangSmith exposes a management REST API. Use it to create projects programmatically when you onboard a tenant.

import httpx
import os

LANGSMITH_API_URL = "https://api.smith.langchain.com"
LANGSMITH_API_KEY = os.environ["LANGSMITH_ADMIN_API_KEY"]  # your root admin key

def create_tenant_project(tenant_id: str) -> dict:
    # Project name is deterministic — easy to look up later
    project_name = f"tenant-{tenant_id}"

    response = httpx.post(
        f"{LANGSMITH_API_URL}/api/v1/sessions",
        headers={"x-api-key": LANGSMITH_API_KEY},
        json={
            "name": project_name,
            "description": f"Traces for tenant {tenant_id}",
        },
    )
    response.raise_for_status()
    return response.json()  # includes "id" (project UUID)

project = create_tenant_project("acme-corp")
print(project["id"])  # store this in your tenant DB record

Expected output:

{"id": "3f2a1b...", "name": "tenant-acme-corp", ...}

If it fails:

  • 403 Forbidden → Your admin key doesn't have session creation permissions. In LangSmith Cloud, only workspace Owner role can create sessions via API.
  • 409 Conflict → Project name already exists. Add a unique suffix or check before creating.

Step 2: Issue a Scoped API Key Per Tenant

LangSmith doesn't support project-scoped API keys natively in the cloud offering — keys are workspace-scoped. The isolation strategy is: issue one API key per tenant and store which project that key is allowed to write to in your own backend.

At runtime, your backend enforces the mapping. The tenant's key never reaches your admin credential.

import httpx
import os

LANGSMITH_API_URL = "https://api.smith.langchain.com"
LANGSMITH_API_KEY = os.environ["LANGSMITH_ADMIN_API_KEY"]

def create_tenant_api_key(tenant_id: str) -> str:
    response = httpx.post(
        f"{LANGSMITH_API_URL}/api/v1/api-keys",
        headers={"x-api-key": LANGSMITH_API_KEY},
        json={
            "description": f"tenant-{tenant_id}",
        },
    )
    response.raise_for_status()
    data = response.json()
    # key is only returned once — store it encrypted immediately
    return data["key"]

tenant_key = create_tenant_api_key("acme-corp")
# store encrypted in your secrets manager (Vault, AWS Secrets Manager, etc.)

Security note: LangSmith returns the plaintext key once on creation. Store it in a secrets manager (AWS Secrets Manager, HashiCorp Vault, GCP Secret Manager) immediately. Never log it.


Step 3: Route Traces to the Correct Project at Runtime

Don't use global environment variables in a multi-tenant app — they're process-wide and not request-scoped. Use LangSmith's tracing_v2_enabled context manager or configure the LangChainTracer directly per request.

from langchain_core.tracers import LangChainTracer
from langchain_core.callbacks import CallbackManager
from langchain_openai import ChatOpenAI
import your_secrets_store  # your abstraction over Vault / AWS SM

def get_tenant_tracer(tenant_id: str) -> LangChainTracer:
    project_name = f"tenant-{tenant_id}"
    api_key = your_secrets_store.get(f"langsmith/tenant/{tenant_id}/api_key")

    return LangChainTracer(
        project_name=project_name,
        client=None,  # LangChainTracer builds its own client from the key below
    )

async def run_chain_for_tenant(tenant_id: str, user_input: str) -> str:
    api_key = your_secrets_store.get(f"langsmith/tenant/{tenant_id}/api_key")
    project_name = f"tenant-{tenant_id}"

    tracer = LangChainTracer(project_name=project_name)

    # Pass the tracer explicitly — no global state touched
    llm = ChatOpenAI(
        model="gpt-4o",
        callbacks=CallbackManager([tracer]),
    )

    # LANGCHAIN_API_KEY must be set per-client, not globally
    # Use langsmith.Client directly for full control
    from langsmith import Client
    ls_client = Client(api_key=api_key)
    tracer_explicit = LangChainTracer(
        project_name=project_name,
        client=ls_client,
    )

    llm_scoped = ChatOpenAI(
        model="gpt-4o",
        callbacks=CallbackManager([tracer_explicit]),
    )

    response = await llm_scoped.ainvoke(user_input)
    return response.content

Why this matters: If you set LANGCHAIN_PROJECT as an env var, every concurrent request in your async server (FastAPI, etc.) fights over that global. Explicit tracer injection is the only safe approach for multi-tenant async workloads.


Step 4: Verify Isolation with a Smoke Test

Before going to production, confirm that traces from two tenants land in separate projects and cannot cross-read.

import asyncio
from langsmith import Client
import your_secrets_store

async def smoke_test_isolation(tenant_a: str, tenant_b: str):
    # Run a chain for each tenant
    await run_chain_for_tenant(tenant_a, "Hello from tenant A")
    await run_chain_for_tenant(tenant_b, "Hello from tenant B")

    # Read traces back using each tenant's own key
    key_a = your_secrets_store.get(f"langsmith/tenant/{tenant_a}/api_key")
    key_b = your_secrets_store.get(f"langsmith/tenant/{tenant_b}/api_key")

    client_a = Client(api_key=key_a)
    client_b = Client(api_key=key_b)

    runs_a = list(client_a.list_runs(project_name=f"tenant-{tenant_a}", limit=5))
    runs_b = list(client_b.list_runs(project_name=f"tenant-{tenant_b}", limit=5))

    # Cross-check: tenant A's key should NOT read tenant B's project
    try:
        cross_runs = list(client_a.list_runs(project_name=f"tenant-{tenant_b}", limit=1))
        # In workspace-scoped keys, this will succeed — which is the known limitation
        # Mitigate by never exposing raw LangSmith keys to end-user tenants
        print(f"WARNING: cross-tenant read succeeded ({len(cross_runs)} runs)")
    except Exception as e:
        print(f"Cross-tenant read blocked: {e}")

    print(f"Tenant A runs: {len(runs_a)}")
    print(f"Tenant B runs: {len(runs_b)}")

asyncio.run(smoke_test_isolation("acme-corp", "beta-inc"))

You should see:

Cross-tenant read blocked: 403 Client Error
Tenant A runs: 1
Tenant B runs: 1

If you see WARNING: cross-tenant read succeeded, your tenant keys are workspace-scoped with full read access. This is the LangSmith Cloud default. See the Production Considerations section below.


Step 5: Automate Key Rotation

API keys should rotate on a schedule or on suspected compromise. Build a rotation function that creates a new key, updates your secrets store, and deletes the old one atomically.

import httpx

async def rotate_tenant_key(tenant_id: str) -> None:
    admin_key = os.environ["LANGSMITH_ADMIN_API_KEY"]
    headers = {"x-api-key": admin_key}

    # 1. Create new key first (never leave a gap)
    create_resp = httpx.post(
        f"{LANGSMITH_API_URL}/api/v1/api-keys",
        headers=headers,
        json={"description": f"tenant-{tenant_id}-rotated"},
    )
    create_resp.raise_for_status()
    new_key = create_resp.json()["key"]
    new_key_id = create_resp.json()["id"]

    # 2. Fetch old key ID from your store before overwriting
    old_key_id = your_secrets_store.get(f"langsmith/tenant/{tenant_id}/key_id")

    # 3. Write new key to secrets store (atomic swap)
    your_secrets_store.set(f"langsmith/tenant/{tenant_id}/api_key", new_key)
    your_secrets_store.set(f"langsmith/tenant/{tenant_id}/key_id", new_key_id)

    # 4. Delete old key — safe after store is updated
    if old_key_id:
        delete_resp = httpx.delete(
            f"{LANGSMITH_API_URL}/api/v1/api-keys/{old_key_id}",
            headers=headers,
        )
        delete_resp.raise_for_status()

    print(f"Rotated key for tenant {tenant_id}")

Run this on a schedule (weekly, or triggered by a security event) via GitHub Actions, a cron job, or your workflow orchestrator.


Verification

# List all tenant projects via admin key
curl https://api.smith.langchain.com/api/v1/sessions \
  -H "x-api-key: $LANGSMITH_ADMIN_API_KEY" \
  | jq '[.[] | select(.name | startswith("tenant-")) | {name, id}]'

You should see:

[
  {"name": "tenant-acme-corp", "id": "3f2a1b..."},
  {"name": "tenant-beta-inc",  "id": "9c7d4e..."}
]

Also confirm traces appear in the correct project in the LangSmith UI: open each project and verify only that tenant's runs appear.


Production Considerations

LangSmith Cloud key scope limitation: As of early 2026, LangSmith Cloud API keys are workspace-scoped, not project-scoped. This means a tenant key can technically read other projects in the same workspace. Mitigate this by:

  • Never giving tenant keys directly to end-users — proxy all LangSmith writes through your backend
  • Using self-hosted LangSmith (Enterprise) if you need true project-level key scoping
  • Treating LangSmith as an internal observability tool, not a tenant-facing product

Self-hosted LangSmith: If you run LangSmith on-prem (Docker Compose or Kubernetes), you get more control over the auth layer. You can place each tenant's LangSmith instance behind your own IAM, or use separate LangSmith workspaces per enterprise customer.

Cost: Each project in LangSmith Cloud counts against your trace quota. Monitor usage per project with the /api/v1/sessions/{session_id}/stats endpoint and alert when a tenant exceeds their allocated trace budget.

Failure mode: If your secrets store is unavailable, your trace routing falls back to the default project (or fails silently if no key is available). Add a circuit breaker that disables tracing rather than using a fallback key — mixing tenant traces is worse than missing traces.


What You Learned

  • LangSmith projects are the right unit of tenant isolation, not tags or metadata
  • API keys are workspace-scoped in LangSmith Cloud — never expose them directly to end-user tenants
  • Explicit LangChainTracer injection (not env vars) is required for safe async multi-tenant trace routing
  • Key rotation should be atomic: create new → update store → delete old

When NOT to use this approach: If you have fewer than 5 tenants with no data isolation requirements, a single project with a tenant_id metadata tag on each run is simpler and sufficient. This architecture pays off at 10+ tenants or when you have contractual data isolation obligations.

Tested on LangSmith SDK 0.3.x, LangChain 0.3.x, Python 3.12, FastAPI 0.115