Managing secrets in LangGraph agents
Frameworks
A LangGraph agent gets its credentials the way any Python process does: from
the environment, through the provider client under ChatOpenAI. That means
the simplest setup is one command with no code changes —
seekrit run -- langgraph dev
— and it also means every credential the process holds is one
os.environ read away from anything running in it. There are four shapes,
in increasing order of how little the agent holds. Pick by what the key can
do, not by what's convenient.
Why the environment stopped being a safe place
Two LangChain-specific reasons, on top of the general one (an agent can be talked into repeating what it can read):
- CVE-2025-68664 (CVSS 9.3):
langchain-core'sload()defaultedsecrets_from_env=True, so a crafted serialized payload could name any environment variable and read it back out. Patched in 1.2.5 / 0.3.81 — and a reminder that "in the environment" means "in reach of the next deserialization bug." - LangSmith is a second credential.
LANGSMITH_API_KEYsits in the same environment as your provider keys and gets forgotten in exactly the way a main key doesn't.
Shape 1 — wrap the process
seekrit run -- python agent.py
seekrit run -- langgraph dev
Every granted secret arrives as an environment variable. Delete the .env
afterwards — a file that still exists is a file
an agent can still read.
Shape 2 — resolve in code
import seekrit
seekrit.Client().into_env() # before constructing the model
Or skip os.environ entirely and pass the key explicitly:
secrets = seekrit.Client().resolve()
model = ChatOpenAI(model="gpt-5.6-terra", api_key=secrets["OPENAI_API_KEY"])
Shape 3 — the process never holds the key
Point the model at the proxy and pass a placeholder. The graph — including any code-generating node in it — holds a string that names a credential without being one:
model = ChatOpenAI(
model="gpt-5.6-terra",
base_url="http://127.0.0.1:8080/openai/v1",
api_key="{{seekrit:OPENAI_API_KEY}}",
)
The proxy substitutes the real key on the way out, against a default-deny
allowlist that also bounds methods and paths. No sidecar? The same
substitution runs in-process as an httpx transport
(seekrit.transport.SeekritTransport) — weaker, since it shares the
process, but it unlocks shape 4.
Shape 4 — scope a key to one tool call
The keys that deserve the most care aren't the model's — a provider key buys tokens; a Stripe key bought by a tool call does something irreversible. LangChain 1.x middleware makes "which credentials may this tool use" a line of configuration:
from seekrit.langchain import SeekritCredentials
agent = create_agent(
model=model,
tools=[refund, search],
middleware=[
SeekritCredentials(
model=["OPENAI_API_KEY"],
tools={"refund": ["STRIPE_SECRET_KEY"]},
),
],
)
refund may substitute the Stripe key and nothing else; an unlisted tool
gets an empty allowlist, so a prompt-injected search call can't reach a
payment credential. The middleware's scope hook also resolves per-tenant
off runtime.context, so one agent process serves many tenants without
holding two tenants' keys at once.
Picking a shape
| The credential | Shape |
|---|---|
| Model key, local dev | 1 — wrap the process |
| Model key, your own deployment | 2, or 3 if the graph runs generated code |
| Anything a tool spends (Stripe, GitHub, a database) | 3 with methods/paths, or 4 |
The full walkthrough — including the LangSmith egress rule you'll need if the forward proxy denies unmatched hosts, and the LangGraph Platform case where seekrit syncs to the platform instead — is in the LangGraph guide. Setup is three commands, and the values are encrypted client-side before they're stored: the service holding them can't read them.