Skip to content

Credential proxy

The credential proxy lets you run an AI agent (or any untrusted component) so that it never sees real secrets. Instead it receives placeholders and real values are swapped in at the network boundary.

This is useful any time a process you don’t fully trust needs to use a secret without being allowed to read it: coding agents, MCP servers, third-party CLIs, or scripts.

This pattern is known as a credential broker: a trusted component holds the real credentials and swaps them in at the network boundary, so a compromised or prompt-injected agent never holds a secret it can leak. It is increasingly recommended for agent security, for example by the SANS Institute and in Anthropic’s managed-agents architecture.

Most other credential brokers require you to store secrets in their tool, or work only with a limited subset of secure storage locations (system keychain, 1Password). Our proxy works with the existing varlock suite - so you can use any of our plugins, and still take advantage of the rest of varlock’s features.

Agentuntrusted childholds placeholdersvarlock proxylocal, in memoryswap / verify TLS / scrubUpstream APIverified TLS hostplaceholderreal secretscrubbedresponse
The agent only ever holds a placeholder. The proxy swaps in the real secret on the wire toward a verified upstream, and scrubs it back out of the response.
.env.schema
# @sensitive
# @proxy(domain="api.stripe.com") @placeholder=sk_test_00000000000000000000000000
STRIPE_SECRET_KEY=yourPreferredPlugin() # from a plugin or built-in encryption — never a plaintext secret
Terminal window
varlock proxy run -- claude

The agent launched above sees STRIPE_SECRET_KEY=sk_test_0000…, a placeholder shaped like a real key. When it makes a request to api.stripe.com, the proxy swaps the placeholder for the real key on the wire. If the agent prints the variable, exfiltrates its env, or sends it anywhere else, all it has is a useless placeholder.

The @placeholder keeps the placeholder valid-looking so the Stripe SDK’s client-side key-format check passes. See Placeholders for when you need it.

  1. You mark which secrets are proxied with @proxy(domain=...).
  2. The child process is launched with placeholders in place of those secrets, plus the standard proxy/CA environment variables pointing at a local proxy.
  3. The proxy intercepts HTTPS traffic. For a request that matches a rule, it verifies the upstream’s real TLS identity, then substitutes the placeholder for the real secret only on that connection.
  4. Responses are scrubbed so real values can’t leak back into the child’s output, and every request is recorded to an audit log.

Secrets and the proxy’s signing key live only in memory on your machine; they are never written to disk and never handed to the child.

This assumes you already have varlock installed and a working .env.schema whose secrets resolve (committed encrypted values, a plugin, or env values you can load).

Mark the item @sensitive and add @proxy(domain=...) to the item you want to protect. The @proxy tells the proxy to inject the item’s real value into requests to that host:

.env.schema
# @sensitive
# @proxy(domain="api.stripe.com")
# @placeholder=sk_test_00000000000000000000000000
STRIPE_SECRET_KEY=yourPreferredPlugin()
# @sensitive
# @proxy(domain="api.github.com")
GITHUB_TOKEN=yourPreferredPlugin()

@proxy already implies @sensitive (and varlock treats items as sensitive by default), so the @sensitive line is optional. We show it explicitly here because being clear about what is a secret is good practice.

The @placeholder on STRIPE_SECRET_KEY makes the placeholder the agent sees look like a real key, so SDK key-format checks pass. Leave it off (like GITHUB_TOKEN above) and the item gets a generic vlk_placeholder_…, which varlock warns about because it can fail a client-side key-format check (see Placeholders).

That is all the proxy needs: there is no separate “enable” step. It runs in permissive mode by default (hosts that don’t match a rule pass through untouched). Add @proxyConfig={egress="strict"} to your schema header only when you want to block everything that isn’t explicitly routed.

Terminal window
varlock proxy rules # prints the effective @proxy rules + per-secret mode, no proxy started
Terminal window
varlock proxy run -- claude
# or any command:
varlock proxy run -- node agent.js
varlock proxy run -- python tool.py

That’s it. The child inherits everything it needs (proxy address + CA trust) automatically.

  • Routing rules: match domains, paths, methods; placeholders and egress modes
  • Running modes: one-shot, daemon, sessions, auditing, sandboxing

The proxy is a local, same-machine tool. Know what it does and doesn’t cover before relying on it:

  • Same host, same user. The proxy runs as you, and so does the agent. It stops the agent from trivially reading a secret it’s using, but on its own it is not a sandbox: it doesn’t isolate the filesystem, other processes, or memory. A determined agent can spawn a process outside the proxy’s view (e.g. reparented via setsid) and resolve secrets directly from their source; the in-tree guards (placeholder isolation, the schema-fingerprint check) raise the bar but are not a hard boundary. Add --sandbox (or a container/VM; see Sandboxing) for a real isolation boundary.
  • Response scrubbing is best-effort. Responses are scrubbed back to placeholders only for small, uncompressed text bodies. Compressed (gzip/br) or large (>2 MB) response bodies are passed through unscrubbed: the proxy can’t scan into them. This only matters if an upstream reflects your secret back in its response (uncommon); the primary protection (the agent only ever holds a placeholder) is unaffected.
  • Injection requires a verified public-TLS host. Secrets are injected only onto connections whose TLS identity is verified against public CAs. The proxy refuses to inject over cleartext http:// or into a self-signed/local upstream, by design.
  • Default egress is not containment. egress=permissive (the default) does not restrict where the agent can connect; it just never injects to unmatched hosts. Use egress=strict (and ideally a sandbox) if you want to constrain egress.
  • Hot-reload is human-applied. proxy reload (or pressing r in an interactive proxy start) re-resolves the schema and swaps the live policy; a reload requested from inside the proxied agent is refused and logged so the agent can’t self-approve a schema edit. Availability follows @proxyConfig={reload=...} (default auto: on for an interactive daemon, off for headless or one-shot runs). But the agent-refusal is a self-reported signal, not authentication: an agent that strips its proxy markers (or runs out-of-tree) can still trigger a reload on a shared uid. A real out-of-band approver is planned to close this; until then, prefer a sandbox.
  • Only proxy-aware clients are covered. The child is pointed at the proxy via standard HTTP(S)_PROXY / CA env vars. A client that ignores those (or pins its own CA) bypasses the proxy entirely; it just won’t get a working secret. See client compatibility for the status of common clients.
  • TLS interception is HTTP/1.1 only. Normal clients negotiate down via ALPN and work fine, but protocols that require HTTP/2 (gRPC) or a connection upgrade (WebSockets) are unsupported on hosts with a @proxy rule. Hosts without a rule tunnel through untouched.