seekrit
Docs/In-process injection

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:

RungBoundaryCost
Environment variablesAnything in the process can read them, and so can anything that can read the process's environmentNothing
In-process injectionThe value exists only inside one HTTP callOne import
The proxyA separate process; your code never has the value at allseekrit proxy run, or npx @seekrit/proxy
Temporary accessThe credential did not exist before the request and stops working afterA lease

What the middle rung genuinely buys you:

  • Nothing in the environment. No .env, no os.environ, no process.env — so an environment-scraping bug in a dependency finds nothing. That is not hypothetical: CVE-2025-68664 in langchain-core (CVSS 9.3) defaulted secrets_from_env=True on 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.

OptionDefaultWhat it does
allow{host: [names]} shorthand: those names toward that host, any method or path
rulesFull rules, host by host. Combines with allow
token / apiUrl$SEEKRIT_TOKENThe service token to resolve with
clientA pre-built client, used when no scope overrides are in play
scopeambientCalled per request to narrow the resolve and the allowlist
ttlSeconds / ttl_seconds60How long a resolved set is reused per scope. 0 resolves every time
bodytrueAlso scan the request body
require_scopefalseRefuse a placeholder-carrying request when no scope is in effect (Python)
refusal"respond""respond" answers 403; "throw" / "raise" raises the typed error
onInject / on_injectCalled after a substitution with host, method, path and names. Never values
onRefuse / on_refuseCalled 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.
  • ttlSeconds is 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. Set 0 for 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 Request object is refused rather than sent, because the URL of a constructed Request cannot be rewritten. Headers on a Request are substituted normally.
  • @ai-sdk/openai calls /v1/responses, not /v1/chat/completions. Current versions default to the Responses API, so a rule pinned to paths: ["/v1/chat/completions"] denies every request from that stack — fail-closed, but confusing. ["/v1/**"] covers both. LangChain's ChatOpenAI and 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-refusal is there to disambiguate, and on_refuse fires 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 login writes ~/.config/seekrit/config.json on a developer machine, and in a deploy it is the single variable your platform holds.