Best practice for per-tenant model config and BYOK with LangGraph Cloud`

Hi everyone,

I’m looking for best practices for a multi-tenant SaaS agent deployed on LangGraph / LangSmith Cloud.

Context:

  • Each customer/org may have its own model configuration.
  • Some orgs may use different providers/models.
  • We may need per-org provider API keys / BYOK.
  • The agent should select the right model configuration at runtime based on the authenticated org.

What is the recommended production pattern for this with LangGraph / LangSmith Cloud?

Specifically:

  1. Is there a built-in or recommended LangGraph / LangSmith Cloud mechanism to store and manage per-tenant model configuration and provider API keys?
  2. Or is the best practice to keep this in our own backend/database and have the graph fetch the active model config at runtime, for example from middleware or wrap_model_call?
  3. If the graph fetches config from our own backend, what is the recommended way to pass the authenticated org/user context into the run?
  4. Are there any gotchas around avoiding provider API keys or sensitive tenant data leaking into graph state, checkpoints, traces, logs, or metadata?
  5. Are there public examples or recommended patterns for multi-tenant BYOK / per-org model selection?

The goal is to have one deployment, dynamic model selection per org/request, and a secure way to handle provider credentials and cost attribution.

Would love to hear what people are doing in production.

Great question — this is a common multi-tenant pattern and there are two supported paths depending on how much you want to own vs. offload. Short version: keep per-tenant config/keys in your own secret store (or LangSmith Provider Secrets), resolve the model per-request in a wrap_model_call middleware, and never let keys touch graph state or traces.

On your Q1 — is there a built-in mechanism? Two options:

  • DIY (GA today): your backend/secret store is the source of truth; the graph fetches the active config at runtime. Most flexible, works on any deployment.
  • LLM Gateway (private beta): a proxy that sits between your agents and providers. Keys are stored once in LangSmith as Provider Secrets; clients authenticate with a LangSmith API key, and the gateway resolves the real provider key, enforces spend limits + PII/secrets redaction, and traces every call. This is likely the cleanest fit for BYOK + cost attribution if you’re open to the beta. → LLM Gateway, custom / OpenAI-compatible providers, governance & spend policies. Gateway spend-cap policies support per-subject matchers with current_spend_usd, which gives you per-tenant cost attribution out of the box. (It’s private beta — worth joining the waitlist.)

Q2 — own backend + fetch at runtime: yes, this is the recommended DIY pattern. Use a wrap_model_call middleware that reads the org from runtime context, looks up that org’s config + key (from your secret store, cached), and overrides the model per-invocation:

from dataclasses import dataclass
from typing import Callable
from langchain.agents import create_agent
from langchain.agents.middleware import wrap_model_call, ModelRequest, ModelResponse
from langchain.chat_models import init_chat_model

@dataclass
class Context:
    org_id: str

@wrap_model_call
def per_tenant_model(request: ModelRequest, handler: Callable[[ModelRequest], ModelResponse]) -> ModelResponse:
    org_id = request.runtime.context.org_id
    cfg = get_tenant_config(org_id)          # your backend/secret store (cache this)
    model = init_chat_model(
        cfg.model,                            # e.g. "openai:gpt-...", "anthropic:...", or an OpenAI-compatible base_url
        api_key=cfg.api_key,                  # BYOK, fetched per-request, never persisted
    )
    return handler(request.override(model=model))

agent = create_agent(model="openai:gpt-4o", middleware=[per_tenant_model], context_schema=Context)

Runtime context is the right home for this: it’s per-run, read-only, and not persisted between invocations — the docs explicitly call out “tenant/workspace context” and “API keys” as its intended use.

Q3 — passing authenticated org context into the run: on LangSmith Deployment, use custom auth. Whatever your @auth.authenticate handler returns is attached to the run and readable in the graph via config["configurable"]["langgraph_auth_user"] — so you resolve the org from the validated token, not from client-supplied input. See Auth overview and the end-to-end multi-tenant auth tutorial (Q5’s public example). You can then map that identity to context for the middleware above.

Q4 — the leakage gotchas (most important part):

Q5 — cost attribution: tag each run with org_id via metadata/tags so cost tracking rolls up per tenant — or lean on Gateway spend policies for hard per-tenant caps.

TL;DR: one deployment + custom auth to establish org identity → runtime context carries org_idwrap_model_call resolves the tenant’s model/key from your secret store (or the LLM Gateway) → keep keys out of state/checkpoints and redact them from traces.

Happy to sketch the custom-auth handler side if useful.

Thanks for the detailed answer. I’ll go with the DIY approach.

I already use custom authentication, which allows me to derive a trusted user_id and org_id from the authenticated Firebase user rather than accepting the organization ID from the request payload.

However, my configuration API cannot authorize access based on org_id alone. It also needs the user’s Firebase bearer token to verify that the user is authorized to access that organization’s secret store. Otherwise, knowing an org_id could be enough to request another organization’s configuration.

My current plan is therefore to include the Firebase bearer token in the MinimalUserDict returned by @auth.authenticate, alongside the trusted user_id and org_id. The wrap_model_call middleware would read these values from langgraph_auth_user and call my internal API to retrieve the tenant’s model
configuration and provider API key just in time.

Since Firebase ID tokens are already short-lived, this seems preferable to retrieving the provider API key during authentication and storing it in langgraph_auth_user. In both cases, sensitive credentials would temporarily be present there, but keeping the Firebase token means the provider key remains confined to the middleware and is retrieved only when needed.

I have already enabled encryption with LANGGRAPH_AES_KEY and LANGGRAPH_AES_JSON_KEYS=bearer_token.

Would you consider storing the short-lived Firebase bearer token in langgraph_auth_user and using it from wrap_model_call to be the recommended pattern in this situation? Or would you still recommend an other solution ?

Your plan is sound — deriving org_id/user_id from custom auth and doing a just-in-time lookup in wrap_model_call is the right pattern, and preferring the short-lived Firebase token over the provider key is a good call. But there’s one important gotcha: as configured, your token is being stored in plaintext.

LANGGRAPH_AES_JSON_KEYS=bearer_token won’t encrypt your token. AES JSON encryption matches top-level key names only — it doesn’t recurse into nested objects. Your token lives inside langgraph_auth_user (configurable.langgraph_auth_user.bearer_token), so the allowlist never sees a bearer_token key and encrypts nothing. The token ends up at rest in plaintext in the run’s stored config.

Fix — allowlist the outer object instead:

LANGGRAPH_AES_JSON_KEYS="langgraph_auth_user"

This encrypts the entire serialized user object — nested Firebase token included — as one blob wherever it’s persisted. This holds regardless of whether you store the Firebase token or the provider key, so it’s worth getting right either way.

A few related notes:

  • Version floor: LANGGRAPH_AES_JSON_KEYS requires Agent Server v0.7.0+ (Jan 2026). Worth confirming your deployment is on that or later.
  • Tradeoff: encrypted fields can’t be searched or filtered — irrelevant for an auth blob, but good to know.

So: yes, this is a good pattern — just change the allowlist to langgraph_auth_user and you’re set.

Docs: Encryption · Custom auth · Dynamic model selection