Agent egress proxy
seekrit-proxy keeps decrypted secrets out of a workload's memory. Where
seekrit-run injects plaintext into a process's environment
— fine when you trust that process with its own keys — the proxy is for
workloads you don't fully trust, like an AI agent that runs arbitrary tools.
The workload sends requests with placeholders; the proxy swaps in the real,
decrypted value on the way to the upstream. The plaintext only ever exists
inside the proxy and in the request to an allowlisted host.
agent ──▶ Authorization: Bearer {{seekrit:EXAMPLE_API_KEY}}
│ (localhost / sidecar — never sees the real key)
seekrit-proxy ── resolves + decrypts once at startup
│ substitutes, checks the allowlist, audits
▼
api.example.com Authorization: Bearer …real key…
The proxy holds a service-token grant, so it is "just another principal" in
the key-grant model — the same mechanism the
CLI, the browser, and seekrit-run use. Its decryption runs through the exact
same zero-knowledge path; the API never sees plaintext.
Why not just use seekrit-run?
seekrit-run puts secrets in the process environment, so the process can read
them — and anything the process can read, it can exfiltrate. For an untrusted
or agentic workload that's the whole problem. The proxy moves the plaintext to a
separate trust boundary: the agent holds only {{seekrit:NAME}}, and a real
credential appears only in the request to the allowlisted upstream.
How it works
- Startup (fail-closed). The proxy reads
SEEKRIT_TOKEN, callsGET /v1/resolve, and decrypts every granted secret into memory. If the token is missing/bad, the API is unreachable, or a layer won't decrypt, it refuses to start — a security control should never silently forward unsubstituted placeholders. - Per request. It matches the request path to a configured route, then
substitutes
{{seekrit:NAME}}in the request path, headers, and body. Only the request is rewritten; the response streams back untouched, so streaming APIs (SSE) work unchanged. - Allowlist (default-deny). Each route declares which secrets may be
injected toward its upstream. A placeholder naming a secret not on that list
— or one that didn't resolve — is refused with
403and never forwarded. - Audit. Each substitution logs the secret names, method, path, and upstream host — never the values.
The allowlist is the security boundary. Without it, a proxy that injects any secret into any request is an exfiltration oracle: an agent could point it at an attacker's host and have a real key filled in. Each route only injects the secrets you list for its upstream.
Configure
Create seekrit-proxy.toml. Each [[route]] maps a path prefix to an upstream
and the secrets it may receive:
listen = "127.0.0.1:8080"
[[route]]
prefix = "/example"
upstream = "https://api.example.com"
allow = ["EXAMPLE_API_KEY"]
The service token comes from the environment, never the (committable) config:
export SEEKRIT_TOKEN=skt_…
seekrit-proxy --config seekrit-proxy.toml
Point your workload at it
Set the workload's base URL to the matching route prefix and pass the credential as a placeholder. Most SDKs take both from the environment:
# Any OpenAI-compatible SDK, for example — nothing here is provider-specific.
export OPENAI_BASE_URL=http://127.0.0.1:8080/example
export OPENAI_API_KEY='{{seekrit:EXAMPLE_API_KEY}}'
A request to http://127.0.0.1:8080/example/v1/models is forwarded to
https://api.example.com/v1/models with EXAMPLE_API_KEY substituted into the
Authorization header. The agent process only ever held the placeholder.
Options
| Flag | Default | Description |
|---|---|---|
-c, --config <path> | ./seekrit-proxy.toml | Route + allowlist config. |
--listen <addr> | from config (127.0.0.1:8080) | Override the listen address. |
-t, --token <skt_…> | SEEKRIT_TOKEN | Service token. |
--api-url <url> | SEEKRIT_API_URL or https://api.seekrit.dev | API base URL. |
Placeholder format
Placeholders are {{seekrit:NAME}}, where NAME matches a resolved secret
([A-Za-z0-9_]+). Anything that isn't a well-formed, terminated placeholder is
left verbatim, so ordinary request content passes through untouched. A proxy
with no matching placeholders is a plain pass-through.
Forward proxy (transparent, via HTTPS_PROXY)
The reverse-proxy model needs the workload to point each SDK's base URL at the
proxy. That's fine for one or two known APIs, but an agent that calls many hosts
(an LLM and GitHub and some tool and whatever it decides to curl) is
better served by the forward-proxy model: the workload sets one environment
variable and every egress flows through the proxy, no per-SDK wiring.
[forward]
listen = "127.0.0.1:8081"
unmatched_host_policy = "tunnel" # or "deny"
ca_cert = "seekrit-proxy-ca.pem"
ca_key = "seekrit-proxy-ca-key.pem"
[[forward.host]]
match = "api.example.com"
allow = ["EXAMPLE_API_KEY"]
For a ruled host the proxy answers the client's CONNECT, terminates TLS
with a leaf certificate it mints for that host (signed by a local CA it
generates once and persists), reads the plaintext request, substitutes, and
re-originates a real TLS request to the upstream. Point the workload at it:
export HTTPS_PROXY=http://127.0.0.1:8081
# Trust the proxy's CA (pick what your runtime reads):
export NODE_EXTRA_CA_CERTS=$PWD/seekrit-proxy-ca.pem # Node
# export SSL_CERT_FILE=$PWD/seekrit-proxy-ca.pem # OpenSSL/curl
# export REQUESTS_CA_BUNDLE=$PWD/seekrit-proxy-ca.pem # Python requests
export ANTHROPIC_API_KEY='{{seekrit:EXAMPLE_API_KEY}}'
The proxy only intercepts hosts that have a rule. A host with no rule is
blind-tunneled through untouched (tunnel, the default) so the agent's other
traffic keeps working, or refused with 403 (deny) for a strict allowlist
of reachable hosts. It never reads or injects into traffic it has no rule for.
TLS interception means the workload must trust the proxy's CA. Its private key stays on the proxy host and only ever signs leaves for hosts you list. Install the CA only into the trust store of the workload you're proxying — not system-wide on a shared machine.
Run in a container
The proxy is published to Docker Hub as seekritdev/proxy — multi-arch, a
single static musl binary on scratch (no OS, no shell), so the runtime image
is nothing but the proxy holding your secrets in memory. latest and
<version> tags are cut on release; edge tracks main. Mount a config, pass
the token in the environment, and publish the port:
docker run --rm -e SEEKRIT_TOKEN=skt_… \
-v "$PWD/seekrit-proxy.toml:/seekrit-proxy.toml" \
-p 8080:8080 seekritdev/proxy --listen 0.0.0.0:8080
The image reads its config from /seekrit-proxy.toml (override with --config)
and the token from SEEKRIT_TOKEN. Which address to bind depends on how the
container is reached:
- Sidecar sharing the workload's network namespace — keep the default
loopback bind (
127.0.0.1), so nothing outside the pod can reach the proxy. - Standalone container — pass
--listen 0.0.0.0:<port>(as above) so the workload's container can connect, and publish only to that container's network.
In forward mode, persist the CA across restarts by mounting a volume for the
ca_cert / ca_key paths. Otherwise the proxy generates a fresh CA on each
start and the certificate the workload trusts stops matching.
Point the config's ca_cert / ca_key at a mounted directory, then mount both
the config and that directory:
# seekrit-proxy.toml
[forward]
listen = "0.0.0.0:8081"
ca_cert = "/ca/seekrit-proxy-ca.pem"
ca_key = "/ca/seekrit-proxy-ca-key.pem"
[[forward.host]]
match = "api.example.com"
allow = ["EXAMPLE_API_KEY"]
mkdir -p ca # first run writes the CA here; later runs reuse it
docker run --rm -e SEEKRIT_TOKEN=skt_… \
-v "$PWD/seekrit-proxy.toml:/seekrit-proxy.toml" \
-v "$PWD/ca:/ca" \
-p 8081:8081 seekritdev/proxy
The generated ca/seekrit-proxy-ca.pem is the cert the workload trusts (via
NODE_EXTRA_CA_CERTS and friends, above); because it lives on the mounted
volume it survives container restarts.
Pin a release tag (e.g. seekritdev/proxy:0.2.0) for reproducible deployments,
or :edge for the latest main. To build the image yourself, see
apps/proxy/Dockerfile — the shared crypto crate is supplied as a named build
context, so build from the repo root.
Both modes can run at once, on different ports, from a single config. See Service tokens for how to mint the token the proxy needs.