seekrit
← all posts

Managing secrets in LangChain agents

Frameworks

LangChain 1.x collapsed most agent code into one call. In Python:

from langchain.agents import create_agent

agent = create_agent(model="openai:gpt-5.6-terra", tools=[search, refund])

In JavaScript:

import { createAgent } from "langchain";

const agent = createAgent({ model: "openai:gpt-5.6-terra", tools: [search, refund] });

Neither line mentions a credential. The model string is resolved to a chat model class that reads OPENAI_API_KEY from the environment, and the tools read whatever they need from the same place. The framework hands the credential question back to you, and the default answer is a .env file.

This post is the LangChain answer in both languages. There is a companion LangGraph post for graphs built directly on LangGraph. Everything here applies to those too, since create_agent is a LangGraph graph underneath.

Why not the environment

Three reasons specific to LangChain, on top of the general one that an agent can be talked into repeating what it can read.

The serializer read the environment. CVE-2025-68664 (CVSS 9.3): langchain-core's load() defaulted to secrets_from_env=True, so a crafted serialized object could name any environment variable and get its value back. Patched in 1.2.5 and 0.3.81. The class of bug is what matters: a value in os.environ is in reach of every code path that touches the environment, including ones you did not know did.

LangSmith is a second credential. LANGSMITH_API_KEY sits beside the provider keys and authorizes writing to a project that holds every prompt and tool call the agent made. It gets skipped in rotations because it is not the key anyone thinks of first.

Tools hold the expensive keys. The model key buys tokens. The tool that calls Stripe holds a key that refunds money, and in the environment model that key is readable by the search tool, the model client, and the tracer.

Shape 1: wrap the process

Zero code, either language:

seekrit run -- python agent.py
seekrit run -- node agent.js
seekrit run -- langgraph dev

Every secret in the token's environment is injected into the child process and nowhere else. Delete the .env afterwards. seekrit run overlays one if it exists, and a file that exists is a file an agent can read.

Shape 2: resolve in code

For a serverless handler or anywhere there is no process to wrap:

import seekrit
from langchain.agents import create_agent

seekrit.Client().into_env()     # before the model is constructed

agent = create_agent(model="openai:gpt-5.6-terra", tools=[search])
import { Seekrit } from "@seekrit/sdk";
import { ChatOpenAI } from "@langchain/openai";
import { createAgent } from "langchain";

const secrets = await new Seekrit().resolve();
const model = new ChatOpenAI({ model: "gpt-5.6-terra", apiKey: secrets.OPENAI_API_KEY });
const agent = createAgent({ model, tools: [search] });

Order matters in Python: ChatOpenAI reads the key when it is constructed, so into_env() has to run first. Passing api_key= explicitly avoids os.environ entirely.

Shape 3: hold a placeholder

The agent does not need the key. It needs requests to api.openai.com to carry it. Both chat model classes let you replace the HTTP layer, which is where the substitution goes:

import httpx
from langchain_openai import ChatOpenAI
from seekrit.transport import AsyncSeekritTransport, SeekritTransport

allow = {"api.openai.com": ["OPENAI_API_KEY"]}
model = ChatOpenAI(
    model="gpt-5.6-terra",
    api_key="{{seekrit:OPENAI_API_KEY}}",
    http_client=httpx.Client(transport=SeekritTransport(allow=allow)),
    http_async_client=httpx.AsyncClient(transport=AsyncSeekritTransport(allow=allow)),
)
import { seekritFetch } from "@seekrit/sdk/fetch";

const model = new ChatOpenAI({
  model: "gpt-5.6-terra",
  apiKey: "{{seekrit:OPENAI_API_KEY}}",
  configuration: { fetch: seekritFetch({ allow: { "api.openai.com": ["OPENAI_API_KEY"] } }) },
});

Now os.environ, process.env, the LangSmith trace, and the model's own config all contain {{seekrit:OPENAI_API_KEY}}. The value is resolved, decrypted, substituted into the outbound request, and discarded. A placeholder that ends up pointed at any other host is refused with a 403 before the request is sent.

Because this runs in the agent's process, it is a weaker boundary than seekrit-proxy, which does the same substitution in a separate process. The proxy is a base_url and a placeholder away when the code holding the placeholder is code you do not trust. The in-process version is the one that makes shape 4 possible.

Shape 4: one tool may spend a key, the others may not

LangChain 1.x middleware wraps every model call and every tool call. In Python seekrit.langchain uses that to make "which credentials may this tool use" a line of configuration:

from langchain.agents import create_agent
from seekrit.langchain import SeekritCredentials
from seekrit.transport import SeekritTransport

transport = SeekritTransport(
    allow={
        "api.openai.com": ["OPENAI_API_KEY"],
        "api.stripe.com": ["STRIPE_SECRET_KEY"],
    },
    require_scope=True,
)

agent = create_agent(
    model=model,          # built with http_client=httpx.Client(transport=transport)
    tools=[refund, search],
    middleware=[
        SeekritCredentials(
            model=["OPENAI_API_KEY"],
            tools={"refund": ["STRIPE_SECRET_KEY"]},
        ),
    ],
)

refund may substitute the Stripe key. search may substitute nothing, since tools is exhaustive when given: an unlisted tool gets an empty allowlist. A prompt injected through a search result cannot reach a payment credential no matter what it says, because the request carrying it is refused at the transport. require_scope=True makes the transport fail closed when no middleware set a scope, so a tool invoked outside the agent cannot inherit the process-wide allowlist by accident.

The same middleware takes scope, a function of runtime.context that returns per-tenant group overrides. One agent process serves many tenants without holding two tenants' keys at once and without a per-tenant model instance.

In JavaScript there is no middleware package yet. The equivalent is a narrowed fetch per tool:

import { tool } from "@langchain/core/tools";
import { z } from "zod";

const stripeFetch = seekritFetch({
  rules: [{ host: "api.stripe.com", methods: ["POST"], paths: ["/v1/refunds"], allow: ["STRIPE_SECRET_KEY"] }],
});

const refund = tool(
  async ({ charge }) => {
    const res = await stripeFetch("https://api.stripe.com/v1/refunds", {
      method: "POST",
      headers: { authorization: "Bearer {{seekrit:STRIPE_SECRET_KEY}}" },
      body: new URLSearchParams({ charge }),
    });
    return res.json();
  },
  { name: "refund", description: "Refund a charge", schema: z.object({ charge: z.string() }) },
);

search has no fetch that can carry the Stripe placeholder, so it cannot spend it. Less declarative than the Python middleware, same property.

LangSmith

Put LANGSMITH_API_KEY in the seekrit environment with the provider keys, so shapes 1 and 2 cover it. If you run the forward proxy with unmatched hosts denied, api.smith.langchain.com is a separate host and needs its own rule, or tracing stops without an error. The trace itself is a reason to prefer shapes 3 and 4: with a placeholder, the request LangSmith records carries {{seekrit:OPENAI_API_KEY}} rather than a key.

Picking a shape

The credentialShape
Model key, local development1
Model key, your own server or a function2, or 3 if the agent runs generated code
Anything a tool spends3 with methods and paths, or 4
Per-tenant keys in one process4, with scope

The LangGraph guide has the same four shapes with every constructor argument spelled out, and the case where the agent deploys to LangGraph Platform and seekrit syncs to it instead. Setup is three commands, and the values are encrypted before they are stored, so the service that holds them cannot read them.