Skip to content

Credential Injection

The usual way to give a process a credential is to put it in the environment. Then anything the process runs can read it, print it, or send it somewhere.

wardcell attaches the credential at the proxy, on the outbound leg, after the request has already been allowed. The sandboxed process never holds it.

zones.toml — the policy says the zone carries an identity
[zones.user]
profile = "developer"
authenticated = true
/etc/wardcell/config.toml — the machine says where it comes from
[plugin]
path = "/usr/local/libexec/wardcell/wardcell-plugin"
sandbox → proxy: gate host → gate request → gate body → ATTACH CREDENTIAL → origin

Injection is last. A request the policy refuses never causes a credential to be resolved for it, let alone attached.

[plugin] path names a binary wardcell execs once per run and talks to over a Unix socket. It is asked when a connection to a host opens, and again — with the request’s method and path — for each request over it, so that a certificate can be cached while a per-request token is re-minted (see Caching, below).

It is asked which host, which zone is asking, and (per request) the method, path, port and scheme, and answers one of two ways: none, or a single credential carrying a client certificate, a list of per-request header operations, or both — or returns an error, which fails the request rather than sending it bare. One plugin serves every zone of every policy root on the machine, which is why the question names the zone. Credential Plugins has the exact bytes, every timeout, and how to test one.

The plugin decides everything: which hosts need a credential, which mechanism, and where the value comes from. zones.toml carries no host list, no header name, and no value — a zone that needs different treatment per host expresses that in the plugin.

That keeps wardcell out of the business of knowing about OAuth2, SPIFFE, your secrets manager, or your internal CA. A plugin that reads one static token from a file is as small as it looks.

Headers are overwritten, never merged — and can be stripped

Section titled “Headers are overwritten, never merged — and can be stripped”

Each header operation is set or remove. A set is Set, not appended: a sandboxed process that pre-forges its own Authorization header does not get it sent alongside — or instead of — the real one. A remove guarantees a header is absent before the request leaves.

remove is the anti-spoofing half of a minted header. A set only governs a request where the plugin actually minted something; on a request where it minted nothing (an anonymous caller, a skipped mint), a header the sandbox forged would otherwise pass through. Pairing an unconditional remove of the identity header with a set when a token exists closes that gap.

The sandboxed process is issued a zone-scoped stub client certificate by the per-run CA for the proxy-facing leg. The proxy presents the real identity the plugin returned on the upstream leg. The stub’s public key is only used to look up which real identity to present; it is never forwarded.

The plugin also says how to verify the origin, in the certificate’s upstream block: ca verifies it against private PEM roots (a service signed by an internal authority) instead of the host trust store, and insecure_skip_verify turns verification off for a server-auth-only gateway (logged, never the default).

A header set is applied to every request, so a refreshed one is used from the next request on. A client certificate is presented only when a connection to the origin opens, so once its expires_at passes wardcell stops reusing that connection: a request already in flight finishes, and the next one opens a new connection with the refreshed identity.

“This host needs no credential” and “the credential service is broken” are different answers and are kept apart.

  • none → the request proceeds unauthenticated. A normal outcome.
  • A plugin that is unreachable, returns something unusable, or times out → the request does not leave, and the caller gets a 502.

Sending it anyway would forward whatever header the sandboxed process had set in place of the identity the zone promised. A request that cannot carry its identity is not a degraded success.

The certificate and the header set are cached independently, on two clocks and keyed differently. The certificate is keyed by host (it depends on no one request) and kept until near its expires_at, when the connection is retired. The header set is keyed by the whole request — host, method, path, port, scheme — because a minted token is bound to its request, so keying by host alone would hand one request’s token to another; it is kept until near headers_expires_at. An absent headers_expires_at is cached for the run; a past one is re-asked every request, which is how a per-request minted token is expressed; a future one caches until then. Each cache is bounded and single-flight — concurrent callers for the same leg share one plugin call rather than stampeding it — and neither none nor a failure is ever cached, so a cold destination costs two calls (one certificate, one first-request header).