seekrit

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…
note

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

  1. Startup (fail-closed). The proxy reads SEEKRIT_TOKEN, calls GET /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.
  2. 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.
  3. 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 403 and never forwarded.
  4. Audit. Each substitution logs the secret names, method, path, and upstream host — never the values.
caution

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

FlagDefaultDescription
-c, --config <path>./seekrit-proxy.tomlRoute + allowlist config.
--listen <addr>from config (127.0.0.1:8080)Override the listen address.
-t, --token <skt_…>SEEKRIT_TOKENService token.
--api-url <url>SEEKRIT_API_URL or https://api.seekrit.devAPI 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}}'
note

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.

caution

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.
note

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.