Credential injection
For: cluster and tenant administrators.
Bind a credential to the host that should receive it. Check the applicable node and tool types before relying on the proxy to keep values out of a plugin process.
A plugin's tool server gets its secrets as values: in its environment, or on a file descriptor. Whatever runs in it, a dependency or code a model talked it into running, can then read the value and send it anywhere its egress allows, even encoded in a URL.
A credential binding keeps the value out of the process: the process holds a placeholder, and the node's egress proxy puts the real value on requests to the one host the binding names. The host gets the value, so it must be a host you trust with it: see what a bound host must not do.
Bind a plugin's own secret
A plugin binds the secrets it holds in its manifest, under requires.inject:
requires:
secrets: [WEATHER_TOKEN]
inject:
WEATHER_TOKEN:
- host: api.weather.example
header: Authorization
format: "Bearer {secret}"
methods: [GET]
path_prefix: /v2
shape: "wx_{32}"
egress: [api.weather.example]
The server reads WEATHER_TOKEN as before and sends it as before (Authorization: Bearer $WEATHER_TOKEN). What it holds is a placeholder such as wx_9fK2…; the proxy replaces it with the value on its way to api.weather.example, and only there.
| Field | |
|---|---|
host | A host name or a wildcard for one additional label, also allowed by plugin egress. See host patterns and public suffixes. |
port | 443 unless set |
header and format | The header the value goes in, and its value with {secret} once (default {secret}). Connection headers (Host, Content-Length, Transfer-Encoding, …) cannot be set |
basic_user | Basic authentication as this user, with the value as the password (instead of header) |
query | A query parameter (instead of header); lint warns, since servers and proxies log URLs |
methods, path_prefix | Narrow the requests the value goes on; a * in the prefix matches within one path segment |
shape | What the placeholder looks like, for a tool that checks a token's format before it sends it: letters, digits and - . _ ~ around one random part {N} or {N:hex} (also lower, upper, base32) of at least 128 random bits. Default oz0ph_<NAME>_{26:base32} |
Host patterns and public suffixes
Use a host name, or *.domain for exactly one label more. Addresses are refused. It must also be in the plugin's egress: a binding allows no host by itself.
A wildcard over a public suffix (*.github.io, *.co.uk) is refused, and a wildcard never covers a host that is a public suffix beneath its domain or under one (*.amazonaws.com does not cover s3.amazonaws.com): such a host belongs to whoever registered it.
SigV4 signing is the exception, since it sends only a signature for the very host: *.s3.eu-north-1.amazonaws.com covers its buckets.
plugin lint refuses a binding of a secret the plugin's processes do not hold, of a setting, of a host outside its egress or an address. For a remote tool, it warns when no binding names the tool's own server (it would get a placeholder there that nothing redeems), and when one request would carry two bindings' placeholders.
Bind a tenant secret
A tenant admin binds any of the tenant's secrets, for every plugin whose processes get it:
oz0 tenant credential bind acme GITHUB_TOKEN --host api.github.com --header Authorization \
--format 'token {secret}' --shape 'ghp_{36}'
oz0 tenant credential list acme
oz0 tenant credential unbind acme cb-4ade67ca80f44f15
Operators list bindings; admins bind and unbind; the audit log records credential.bound and credential.unbound with names and hosts, never a value. Processes pick a change up at their next start (the node restarts its runtime, as for a rotated secret).
Bind a connection's token
A connection's token comes with each call rather than to the process at its start, so it is bound by the connection's name. In the plugin's manifest:
requires:
inject:
github: # the connection's name, not a secret's
- host: api.github.com
header: Authorization
format: "token {secret}"
shape: "ghs_{36}"
egress: [api.github.com]
connections:
- name: github
tools: [gh]
# ...
or as a tenant admin, for a connection an installed plugin declares:
oz0 tenant credential bind acme --connection github --host api.github.com --header Authorization \
--format 'token {secret}' --shape 'ghs_{36}'
Each covered call then gets a sealed token:
- Every node makes a sealing key when it starts (HPKE: ML-KEM-768 with X25519, or with P-256 in the FIPS flavour, and AES-256-GCM) and publishes its public half; the private half stays in the memory of the node's supervisor.
- The edge mints the call's token as before, makes a placeholder in the binding's shape, and seals both to the node that makes the call, with the node, the tenant, the connection, the call, the hosts the connection's bindings name and an expiry (the token's own, an hour at most).
- The call's
_meta["oz0/connections"]carries the placeholder; the tool sends it as it sent the token. The sealed blob goes to the call's egress proxy, outside every tree the tool sees, for as long as the call runs. - The proxy has the supervisor open the blob and puts the token on requests to the listed hosts only, and scrubs it from the answers. A blob copied to another node does not open there, an expired one does not open at all, one handed to another call is refused, and once the call ended its placeholder is worth nothing.
The audit log records the use as credential.injected with the secret named OZ0_CONNECTION_<NAME> (OZ0_CONNECTION_GITHUB). A call whose server runs under the binding never gets the token in plaintext: an edge or a node that cannot seal it (a node binary from before this, a Windows node) makes the call fail with a reason, and a node that cannot open sealed tokens does not start the server. A binding of a connection's own secrets (GITHUB_APP_TOKEN, a fallback such as GITHUB_TOKEN) is refused with the connection's name to bind instead.
Sign AWS requests
A federated AWS connection's token is a session STS issued for the agent: an access key ID, a secret and a session token, which an AWS API never takes as a header value. Its binding signs instead:
requires:
inject:
aws: # the federated connection's name
- host: "*.s3.eu-north-1.amazonaws.com"
sigv4: {service: s3, region: eu-north-1}
- host: bedrock-runtime.eu-north-1.amazonaws.com
sigv4: {service: bedrock, region: eu-north-1}
The tool gives its AWS SDK the call's placeholder as the access key ID, any secret access key and no session token.
The proxy recognises the placeholder in the signature's Credential=, takes the SDK's Authorization, X-Amz-Date
and X-Amz-Security-Token off, and signs the request again with the session for the binding's service and region,
over the path exactly as it is sent. Only a connection's binding may sign; a tenant secret cannot.
The body's hash: the proxy keeps the SDK's x-amz-content-sha256 when it is a SHA-256, UNSIGNED-PAYLOAD or
STREAMING-UNSIGNED-PAYLOAD-TRAILER (S3 checks the body against it; other services hash what they receive), and
hashes a body of up to 16 MiB itself when the SDK declared nothing. Uploads that sign every chunk with the SDK's own
key (STREAMING-AWS4-HMAC-SHA256-PAYLOAD) are refused with a reason: configure the SDK for unsigned payloads with a
trailing checksum, which current SDKs use over HTTPS by default. The answer is scrubbed of the access key ID, the secret
and the session token.
What the proxy does
TLS and host identity
The proxy terminates TLS only for bound hosts. Every other host keeps its blind tunnel. For a bound host the proxy presents a certificate from a CA of its own, made for each sandbox and kept in memory, which may sign only for the bound hosts. The process trusts it through SSL_CERT_FILE, REQUESTS_CA_BUNDLE, CURL_CA_BUNDLE, GIT_SSL_CAINFO, AWS_CA_BUNDLE, DENO_CERT and PIP_CERT, which name a bundle of the node's roots and the CA, and NODE_EXTRA_CA_CERTS, which names the CA (NODE_USE_ENV_PROXY=1 is set for Node.js). A container gets the bundle over its image's root files.
The proxy checks the request first: the TLS server name must be the bound host, and so must every request's authority inside the tunnel (the Host header, HTTP/2's :authority or an absolute URL's host), port included; one that names another host is refused as request_authority_mismatch, and the request goes on with the tunnel's own host, never the client's header.
A tunnel the proxy does not terminate (every host without a binding) gets only the server-name check: what is inside it stays encrypted.
A request that carries one of the tenant's secrets is refused (what is scanned), and so is every request while the node's copy of the fingerprints is missing or cannot be read; a path-scoped binding takes only a canonical path, forwarded exactly as checked.
Placeholder placement and response scrubbing
The proxy puts the value only where the binding says, when the request carries the placeholder there; the binding's format replaces what the client sent. A placeholder anywhere else (the URL, another header, the body, another method or path), or in any plain-HTTP request, refuses the request with an egress denial of kind placeholder, which the job shows like any other denial.
The proxy scrubs the value from the answer: an API that echoes the credential in a header's value or name, the body or a trailer hands the process the placeholder again. The value's raw form, its base64, hex and percent-encoded forms are replaced; a call's token stays on that list until it expires, after its call ended.
One proxy keeps at most 256 different tokens this way, those of running calls and of calls that ended together, and calls of one connection that share a token count once. A call that would pass the bound goes without its token, so the host refuses it, and the proxy logs why; it gets the token when the first of them expires.
Responses and protocol changes
The proxy passes on final answers only. An interim answer (100, 102, 103) is not forwarded, whatever it holds; a client that sent Expect: 100-continue gets its 100 from the proxy. A failure is a page of the proxy's own with a fixed reason, never a quote of what the server sent, and the proxy's log keeps the same fixed reason. Each request goes to the host on a connection of its own, one TLS handshake each, and what a server sends outside an answer is never read, so nothing can repeat it.
The proxy refuses a switch of protocols: a request to a bound host that asks for one (Upgrade, so WebSocket and h2c; CONNECT inside the tunnel; HTTP/2's preface) is refused before anything goes out, with or without a placeholder, and an answer that switches anyway is refused too. Either is an egress denial of kind protocol_switch, and the process reads why. A stream is what the proxy cannot scrub, so a host that needs an upgrade cannot have a binding: leave it unbound and let the tool hold the secret, or use its HTTP API.
Audit records
The proxy records uses as credential.injected in the audit log: which binding (its ID, or plugin:<SECRET>[n] for a manifest's), which secret, which host and path prefix, once per job and binding, also for a server that serves many jobs.
Where it applies
| Where the server runs | |
|---|---|
| Sandbox tier 1, a container | The sandbox's own egress proxy intercepts; the process has no other way out |
A plugin sandbox on the host's network (--plugin-sandbox auto) | A proxy of the process's own on the node's loopback, with a token only its environment holds. Nothing forces the process through it: a client that ignores HTTPS_PROXY reaches the host with the placeholder, which fails. The sandbox hides the node's other processes, so the value is out of reach |
Tier 0, sandbox: host | The same proxy, and the value is out of the process's environment, files and logs, which keeps it from accidental leaks. It is not a boundary against a malicious plugin: the process runs as the node's user and can read other processes of that user (the runtime's environment, and where ptrace is allowed, the proxy's memory). Use sandbox tier 1 or a container where that matters |
| A Windows node, a node binary from before this | The server does not start, rather than start with the value |
A tool with isolation: microvm or isolation: remote (tier 2) | The server does not start: a microVM's runner takes no bindings yet, and a remote sandbox's traffic never passes the node's proxy |
| A harness session | Its sandbox's egress proxy intercepts, as for a server in tier 1; at tier 0, a proxy on loopback as above. The session's processes hold the placeholder; a terminal or a verify script it starts holds no value either |
A remote harness (protocol: remote) | Nothing is injected: the harness sends its plugin's secrets to its provider itself, so each session's request carries every secret of its plugin as a value, bound or not. An outside agent's session gets only the one secret its auth names, also as a value |
A remote tool (url) | The node's runtime is its client: its MCP traffic goes through a proxy of its own on the node's loopback (in this guide) |
A Windows node injects nothing: its harness sessions and remote tools get the value itself, and its remote tools a bound connection's token itself. A Linux or macOS node binary that cannot inject does not start a session or a remote tool under a binding, rather than hand it the value.
Clients that pin certificates or bring their own roots (Java, some Rust clients) fail on bound hosts, and only there. HTTP/2 is not offered on intercepted connections; clients fall back to HTTP/1.1. WebSocket and other upgrades do not work on a bound host (above).
A node operator whose bound hosts sit behind a TLS-inspecting company proxy names its roots with OZ0_EGRESS_UPSTREAM_ROOTS=<file> in the node's environment.
Remote tools
A remote tool's server is somewhere else, and the node's runtime is the client that sends it each MCP request. Under a binding, the runtime starts the node binary's credential proxy for the tool on loopback, sends the tool's traffic through it (trusting only the node's roots and the proxy's own CA), and puts placeholders where values were:
- A bound secret in the tool's
headers(X-Api-Key: ${API_KEY}) is sent as its placeholder, and the proxy puts the value on, on the binding's host, port, methods and path only. Bind the secret to the server's own host for it to work there. - A bound connection is asked for sealed, as for a local server. When one of the connection's bindings names the server's own host and port, the call carries the placeholder in that binding's header (or as Basic authentication), for that call only, and nothing of the token in its body's
_meta; the proxy opens the call's sealed blob and puts the token there.
A remote server that takes a connection's token reads it from that header. A connection bound only to other hosts gives the server the placeholder in_meta, which nothing redeems: the call fails rather than hand the token to a host it was not bound to. - One request carries one binding's placeholder. A remote tool whose header sends a bound secret to its server and whose bound connection's token goes there too would carry two, which the proxy refuses;
plugin lintwarns. A query-parameter binding cannot reach a remote tool's calls (the runtime opens its stream once), so such a call is not made.
tools:
- id: tracker
url: https://mcp.tracker.example/mcp
headers: {X-Api-Key: "${TRACKER_KEY}"}
requires:
secrets: [TRACKER_KEY]
inject:
TRACKER_KEY:
- {host: mcp.tracker.example, header: X-Api-Key}
egress: [mcp.tracker.example]
What a bound host must not do
The process never holds the value, and the proxy keeps it out of everything the process reads from the bound host in a form the proxy knows. That is a promise about what the proxy can compare, not about what a server can do with a value it was given. The scrub finds the value as it was sent and in its common encodings. It cannot find a value the server has changed:
- JSON that escapes the value's characters (
\u0061), a value in another letter case, or one compressed or encrypted inside a body; - a value divided in pieces, across two headers, two
Rangeanswers or two requests; - a value the server keeps and hands back on a port or a host the binding does not name, which the proxy does not read.
Any scrubber that compares text has these limits. The rule that follows: bind a secret only to a host you trust not to transform the credential and hand it back. A host that does so, by malice or by a debug endpoint that dumps requests, gives whatever process can ask it the value, and that process can send it to any host on its allowlist. Keep a bound host's egress entry to that host's API, keep path_prefix and methods narrow, and prefer a credential that the host can scope and that you can revoke.
Secrets in a model's arguments
Some tools take a secret as an argument: a deploy tool's environment, a generic HTTP tool's header. A tool marks those arguments in its manifest, by function and JSON pointer:
tools:
- id: deploy
mcp: [python, -m, deploy.server]
secret_arguments:
run: [/env/API_KEY]
and a tenant admin binds a secret to one of them:
oz0 tenant credential bind acme DEPLOY_KEY --function deploy.run --argument /env/API_KEY
The model writes {{secret:DEPLOY_KEY}} there. The node fills the value in inside the call, after the tool policy, the approvals and the hooks, and replaces the value, in any common encoding, with {{secret:DEPLOY_KEY}} in the result and in an error the call raises; a file the tool returns that holds the value is dropped.
The job's history, its trace and the approval show what the model wrote. A placeholder anywhere else (another argument, another secret, a secret that is not set) stops the call before it is made, and the model reads which function, which argument and which secret, never a value. A secret a connection, a model provider or a plugin source reads stays on the edge and cannot be bound to an argument.
A tool in a remote sandbox gets no argument bindings: its calls leave the node with their arguments, so plugin lint refuses secret_arguments on it, and a call to it that holds a placeholder is not made.
An argument binding belongs to the installed plugin that marks the argument when you bind it: binding an argument no installed plugin marks is refused, and removing the plugin removes its argument bindings (the audit log records credential.unbound), so another plugin that later takes the tool's ID does not get the value.
Not yet
- Windows nodes inject nothing (above); on tier 0 and
sandbox: hostthe placeholder guards against accidents, not a malicious plugin. - A remote harness's sessions get their plugin's secrets as values (above): a binding of the plugin's secret does not keep it from the harness's code.
- A harness session gets no connection's token: a connection's token is minted for one call, and a session's own tools are not calls the platform makes.
- Substitution in request bodies, HTTP/2, WebSocket and other upgrades on a bound host, and a Java truststore.
- SigV4 presigned URLs and chunk-signed uploads: a sigv4 binding signs the Authorization header only.