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 credential | Shape |
|---|---|
| Model key, local development | 1 |
| Model key, your own server or a function | 2, or 3 if the agent runs generated code |
| Anything a tool spends | 3 with methods and paths, or 4 |
| Per-tenant keys in one process | 4, 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.