In-process injection
The egress proxy gives you a strong property: your
code holds {{seekrit:OPENAI_API_KEY}} and never the key. It also asks you to
run a process — seekrit proxy run these days, so not much of an ask.
The SDKs ship the same substitution engine as a fetch wrapper and an httpx
transport, so you can have the placeholder without the process. It is a weaker
boundary — see what this does and does not
guarantee — and it is one import.
Either way you need a seekrit token, the same one seekrit run uses: it is what
resolves the value the placeholder stands for. If you don't have one yet, that is
three commands.
TypeScript
npm install @seekrit/sdk
import { createOpenAI } from "@ai-sdk/openai";
import { seekritFetch } from "@seekrit/sdk/fetch";
const openai = createOpenAI({
apiKey: "{{seekrit:OPENAI_API_KEY}}",
fetch: seekritFetch({ allow: { "api.openai.com": ["OPENAI_API_KEY"] } }),
});
That covers more than the AI SDK. Mastra
accepts an AI SDK provider instance anywhere it accepts a 'provider/model'
string, and LangChain.js passes configuration straight through to the OpenAI
client:
import { ChatOpenAI } from "@langchain/openai";
const model = new ChatOpenAI({
apiKey: "{{seekrit:OPENAI_API_KEY}}",
configuration: { fetch: seekritFetch({ allow: { "api.openai.com": ["OPENAI_API_KEY"] } }) },
});
Mastra
Mastra types model as a model or a function of { requestContext }, which
makes a different key per request a first-class thing. @seekrit/sdk/mastra
returns that function form, and shares one fetch per scope so the resolve stays
cached:
import { createOpenAI } from "@ai-sdk/openai";
import { seekritModel } from "@seekrit/sdk/mastra";
model: seekritModel(
({ apiKey, fetch }) => createOpenAI({ apiKey, fetch })("gpt-5.6-sol"),
{
secret: "OPENAI_API_KEY",
allow: { "api.openai.com": ["OPENAI_API_KEY"] },
scope: (rc) => ({ with: { tenants: String(rc.get("tenant")) } }),
},
)
It also carries seekritRequestContext (server middleware that lifts a tenant
header into the request context) and seekritToolFetch (a fetch narrowed to
one tool's secrets). See the
Mastra page for the whole shape.
Python
pip install 'seekrit[httpx]'
import httpx
from openai import OpenAI
from seekrit.transport import SeekritTransport
transport = SeekritTransport(allow={"api.openai.com": ["OPENAI_API_KEY"]})
client = OpenAI(
api_key="{{seekrit:OPENAI_API_KEY}}",
http_client=httpx.Client(transport=transport),
)
One transport covers every Python toolkit, because they all reach the network
through the same http_client=:
allow = {"api.openai.com": ["OPENAI_API_KEY"]}
transport = SeekritTransport(allow=allow)
async_transport = AsyncSeekritTransport(allow=allow)
# LangChain / LangGraph
ChatOpenAI(api_key="{{seekrit:OPENAI_API_KEY}}", http_client=httpx.Client(transport=transport),
http_async_client=httpx.AsyncClient(transport=async_transport))
# Pydantic AI
OpenAIProvider(api_key="{{seekrit:OPENAI_API_KEY}}", http_client=httpx.AsyncClient(transport=async_transport))
# OpenAI Agents SDK
set_default_openai_client(AsyncOpenAI(api_key="{{seekrit:OPENAI_API_KEY}}",
http_client=httpx.AsyncClient(transport=async_transport)))
# LlamaIndex
OpenAI(api_key="{{seekrit:OPENAI_API_KEY}}", http_client=httpx.Client(transport=transport))
Use AsyncSeekritTransport for the async client; the arguments are identical.
CrewAI goes through LiteLLM, whose only seam is a module global:
import litellm
litellm.client_session = httpx.Client(transport=transport)
litellm.aclient_session = httpx.AsyncClient(transport=async_transport)
That works, but it is process-wide — per-request scoping is not expressible there.
The allowlist is the point
The placeholder is not the security boundary; the allowlist is. Without one, a substitution engine just moves the key from your source to your imports.
from seekrit.transport import AllowRule, SeekritTransport
transport = SeekritTransport(
rules=[
AllowRule(
host="api.openai.com",
methods=("POST",),
paths=("/v1/chat/completions", "/v1/embeddings"),
allow=("OPENAI_API_KEY",),
),
AllowRule(
host="api.stripe.com",
methods=("POST",),
paths=("/v1/refunds",),
allow=("STRIPE_SECRET_KEY",),
),
],
)
Default-deny throughout, and the same semantics the proxy enforces: first
matching rule wins, an empty methods or paths means any, an empty allow
means no secret. * matches within one path segment and ** across several.
A refusal names the constraint that refused — secret_not_allowed is a different
problem from path_not_allowed, and a default-deny rule set fails in exactly
the confusing direction.
AllowRule is the wire shape of a rule inside a signed
ap1. policy bundle, so rules from a bundle
you have verified can be passed in unchanged.
Two things always fail closed, and neither sends the request:
- a placeholder the allowlist refuses toward this host, method, or path
- a placeholder that names a secret your token did not resolve
Forwarding the literal placeholder would leak an internal name to the upstream; substituting anyway would hand a credential to a host that is not allowed to have it. The refusal carries the secret's name and never its value.
How a refusal reaches you
A refusal answers with the same 403 the proxy answers with, and never sends the request:
403 placeholder {{seekrit:STRIPE_SECRET_KEY}} is not allowed toward this upstream
x-seekrit-refusal: denied
x-seekrit-secret: STRIPE_SECRET_KEY
That is deliberate, and it is the one place where the obvious implementation is
wrong. A provider SDK wraps anything its HTTP layer throws into its own opaque
error and retries it — with LangChain's defaults, a denied placeholder became
APIConnectionError: Connection error. after six retries, with the real reason
buried on err.cause. As a 403 the same mistake surfaces once, immediately, as
PermissionDeniedError: 403 placeholder {{seekrit:STRIPE_SECRET_KEY}} is not allowed toward this upstream. It also means swapping this shim for the proxy
does not change your error handling: both answer 403.
The x-seekrit-refusal header tells our refusal apart from a real upstream 403.
If you call the shim yourself rather than handing it to an SDK, ask for the
typed error instead — refusal: "throw" in TypeScript, refusal="raise" in
Python — and use onRefuse/on_refuse to observe either way.
A failure to resolve — the seekrit API being unreachable — always raises, in both modes. That one is genuinely transient and worth a retry; a denial never is.
Scoping a key to one tool
An agent's provider key buys tokens. Its Stripe key does something irreversible. The interesting question is not "does this process hold the key" but "may this tool call use it" — and on LangChain that is expressible.
pip install 'seekrit[langchain]'
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],
context_schema=Context,
middleware=[
SeekritCredentials(
scope=lambda ctx: {"tenants": ctx.tenant},
model=["OPENAI_API_KEY"],
tools={"refund": ["STRIPE_SECRET_KEY"]},
),
],
)
The refund tool may substitute the Stripe key and nothing else. search may
substitute nothing at all — when tools is given it is exhaustive, so a tool
that is not named there gets an empty allowlist. A prompt-injected search
call cannot reach a payment credential, and the model call itself is narrowed to
the provider key.
scope also picks which secrets to resolve: returning
{"tenants": ctx.tenant} resolves that tenant's slice of a composed group, so
one agent process serves many tenants without ever holding two tenants' keys at
once. Note what this does not do: it never rebuilds the chat model. A model
constructs its HTTP client once, so a per-tenant model instance would mean a
per-tenant object cache and a per-tenant connection pool. The middleware sets an
ambient scope instead, and the transport resolves against it when the request
goes out.
Pair the middleware with require_scope=True, as above. The scope travels in a
context variable, which propagates into the same task and into threads started
with asyncio.to_thread, but not through every possible executor hop. With
require_scope set, a lost scope refuses the request; without it, a lost scope
would fall back to the transport's unnarrowed rules — which is the wrong
direction to fail.
Pydantic AI
WrapperToolset.call_tool wraps every tool invocation with ctx.deps in scope,
so the same per-tool narrowing works there:
pip install 'seekrit[pydantic-ai]'
from pydantic_ai.toolsets import FunctionToolset
from seekrit.pydantic_ai import SeekritToolset
toolset = SeekritToolset(
FunctionToolset([refund, search]),
scope=lambda deps: {"tenants": deps.tenant},
tools={"refund": ["STRIPE_SECRET_KEY"]},
)
A toolset only wraps tools, and Pydantic AI builds its provider when the agent
is constructed — so wrap the run in use_scope to cover the model call too. The
two compose: the run establishes the tenant, the toolset narrows per tool. See
the Pydantic AI page.
What this does and does not guarantee
This runs in your process. Code in that process can read the resolved value or replace the transport, so it is not the trust boundary the proxy is. Think of it as a ladder:
| Rung | Boundary | Cost |
|---|---|---|
| Environment variables | Anything in the process can read them, and so can anything that can read the process's environment | Nothing |
| In-process injection | The value exists only inside one HTTP call | One import |
| The proxy | A separate process; your code never has the value at all | seekrit proxy run, or npx @seekrit/proxy |
| Temporary access | The credential did not exist before the request and stops working after | A lease |
What the middle rung genuinely buys you:
- Nothing in the environment. No
.env, noos.environ, noprocess.env— so an environment-scraping bug in a dependency finds nothing. That is not hypothetical:CVE-2025-68664inlangchain-core(CVSS 9.3) defaultedsecrets_from_env=Trueon deserialization, which let a crafted payload read any named environment variable back out. - Nothing in model context. The value never becomes a tool argument, a tool result, a prompt, or a log line. In an agent that is the leak path that actually fires — every message passes through a trace exporter.
- A real allowlist. Per host, per method, per path, default-deny.
What it does not buy you: protection from the code holding the placeholder. When
that code is a sandbox, model-generated, or an agent
whose next action you cannot predict, use the proxy — and note that the rung
above is now one command (seekrit proxy run, or npx @seekrit/proxy with no
Node install of ours), not a build step. The gap between these two rungs is
smaller than it was, so prefer the stronger one when you can.
Only requests that carry a placeholder are gated. A request without one passes straight through untouched — this is a credential shim, not an egress firewall, and silently blocking unrelated traffic would be a worse lie than not blocking it. For default-deny on all egress, that is the forward proxy.
Options
Both shims take the same set.
| Option | Default | What it does |
|---|---|---|
allow | — | {host: [names]} shorthand: those names toward that host, any method or path |
rules | — | Full rules, host by host. Combines with allow |
token / apiUrl | $SEEKRIT_TOKEN | The service token to resolve with |
client | — | A pre-built client, used when no scope overrides are in play |
scope | ambient | Called per request to narrow the resolve and the allowlist |
ttlSeconds / ttl_seconds | 60 | How long a resolved set is reused per scope. 0 resolves every time |
body | true | Also scan the request body |
require_scope | false | Refuse a placeholder-carrying request when no scope is in effect (Python) |
refusal | "respond" | "respond" answers 403; "throw" / "raise" raises the typed error |
onInject / on_inject | — | Called after a substitution with host, method, path and names. Never values |
onRefuse / on_refuse | — | Called on every refusal, whichever way it surfaces |
Gotchas
- A streamed request body is never scanned. Buffering it here would break streaming uploads, so put placeholders in headers — which is where credentials go anyway. Header and URL substitution always happen.
- A substituted value is never rescanned. If a secret's value happens to
contain
{{seekrit:OTHER}}, that text is emitted literally. A stored value cannot be used to reach a second secret. ttlSecondsis a real trade-off. The default reuses a resolved set for a minute per scope, so a rotation takes up to a minute to be picked up. Set0for correctness at the cost of one round trip per model call.- In TypeScript, pass the URL as a string. A placeholder in the URL of a
Requestobject is refused rather than sent, because the URL of a constructedRequestcannot be rewritten. Headers on aRequestare substituted normally. @ai-sdk/openaicalls/v1/responses, not/v1/chat/completions. Current versions default to the Responses API, so a rule pinned topaths: ["/v1/chat/completions"]denies every request from that stack — fail-closed, but confusing.["/v1/**"]covers both. LangChain'sChatOpenAIand LiteLLM still use chat completions, so the two families need different pins if you constrain paths at all.- A refusal looks like an upstream 403 unless you check the header. That is
the trade for not being retried six times.
x-seekrit-refusalis there to disambiguate, andon_refusefires locally. - The seekrit token is still a credential. It is the one thing the shim
itself must hold, and it decrypts everything the token grants. Keep it out of
the environment too:
seekrit loginwrites~/.config/seekrit/config.jsonon a developer machine, and in a deploy it is the single variable your platform holds.