Credential Plugins
credentials.resolve decides what identity an outbound request carries: a
connection-level client certificate, a set of per-request headers, or both at
once. wardcell asks about a host when it opens a connection, and again — with the
request’s method and path — for each request over it; the plugin decides which
hosts need what, where the values come from, and when they expire.
For what wardcell then does with the answer — where injection happens in the request’s life, why headers are set rather than merged, how mTLS identities are swapped — see Credential Injection. This page is the wire protocol.
[plugin]path = "/usr/local/libexec/wardcell/wardcell-plugin"[zones.user]profile = "developer"authenticated = trueOne plugin serves every zone of the policy root, and every zone of every other
policy root on the machine. authenticated = true anywhere is what makes
credentials.resolve a requirement; a plugin that does not implement it fails
the run before it starts.
Lifetime
Section titled “Lifetime”- wardcell picks a socket path and execs the plugin once per run, with
WARDCELL_PLUGIN_SOCKETset to that path. Nothing else is passed. - The plugin binds and listens on that path. wardcell is the client.
- wardcell retries connecting with backoff for about ten seconds, then sends the
wardcell.inithandshake — the run’s id, its CA certificate, and a shared directory to write into — and reads back the methods the plugin implements. A plugin that has not bound, or does not answer, fails the run’s startup rather than the first request that needed it. - One connection, many questions. The connection lasts the run and is shared with everything else wardcell asks — including the proxy of every zone entered from this run.
- The plugin is killed when the run ends.
The question
Section titled “The question”wardcell resolves a host from two places, and the shape of the question says which.
// At connection time (the connector): the real destination, no request.{"jsonrpc": "2.0", "id": "0191e7…-4", "method": "credentials.resolve", "params": {"host": "api.internal.example.com", "zone": "user", "port": 8443, "ip": "100.66.12.7"}}
// Per request (the request handler): the method and path a token is bound to.{"jsonrpc": "2.0", "id": "0191e7…-5", "method": "credentials.resolve", "params": {"host": "api.internal.example.com", "zone": "user", "method": "POST", "path": "/v1/orders", "port": 443, "scheme": "https"}}zone is the zone the asking sandbox is in, absent rather than null when the
run is in none — one plugin serves every zone and tells them apart by it.
The connection-level resolve carries the real destination: host, the real
port (never 0), and ip — the address wardcell will dial — so a plugin that
shapes a server certificate can probe the same origin the connection targets.
ip is present only when wardcell already has an address: a service-discovery or
pinned instance. For a plain name it has not resolved, ip is absent and the
plugin resolves host itself.
method, path, port and scheme describe one request, and are present only
on a per-request resolve. They are what a per-request token is bound to
(path the resource, method the action). The connector resolves a connection, which
carries many requests and names no single one, so it sends no method, path or
scheme — and the mTLS identity it is after depends on none of them. Their absence
is the cue to answer with the certificate and leave any per-request token to the
call that carries the request.
The two answers
Section titled “The two answers”Each is one response naming the id: none, or a single credential carrying an
optional connection-level mtls identity and an optional list of per-request
headers. Some upstreams need both at once; a header-only gateway omits mtls;
a pure-mTLS host omits headers.
{"jsonrpc": "2.0", "id": "0191e7…-4", "result": {"type": "none"}}
{"jsonrpc": "2.0", "id": "0191e7…-5", "result": { "type": "credential", "mtls": { "cert_chain": "-----BEGIN CERTIFICATE-----\n...", "private_key": "-----BEGIN PRIVATE KEY-----\n...", "expires_at": "2026-08-23T18:04:00Z", "upstream": {"ca": "-----BEGIN CERTIFICATE-----\n...", "server_name": "myservice.internal", "insecure_skip_verify": false} }, "headers": [ {"name": "X-Access-Token", "action": "remove"}, {"name": "X-Access-Token", "value": "signed-token...", "action": "set"} ], "headers_expires_at": "2026-08-23T18:04:30Z"}}none | this host needs nothing. The request proceeds unauthenticated — a normal outcome, and never cached, so wardcell asks again on the next connection |
mtls | the connection to the origin is opened with this certificate and key as its TLS client identity. cert_chain and private_key are PEM; there is no second call to fetch them. expires_at retires the connection. Omit it for a headers-only host |
mtls.upstream | how wardcell verifies the origin, when the host trust store is not what you want. ca verifies against these PEM roots instead (a service signed by a private authority); insecure_skip_verify turns verification off (logged). server_name, when set, is sent as the ClientHello SNI and is the name the server certificate is verified against, in place of the host dialled — so a service reached by instance address can present and be checked as its own name (it still applies under insecure_skip_verify, choosing which certificate the server returns even though that certificate is then not checked). expected_identity is reserved but not enforced — an application identity lives in a certificate field whose encoding differs per issuer, so a connection that sets it is refused rather than opened on a check wardcell cannot make neutrally; server_name covers the common case by pinning the server to a certificate SAN |
headers | a list applied to each request, in order. action is set (make the header exactly value, replacing any the sandbox sent — Set, not Add) or remove (ensure it is absent). A remove carries no value |
headers_expires_at | the lifetime of the whole header set. Absent never expires (wardcell caches it); a time in the past means “re-ask every request”, which is how a per-request minted token is expressed; a future time caches it until then |
server_cert | the leaf wardcell presents to the sandbox client for this host, when a bespoke client checks a server identity carried in the certificate. Connection-level only. Omit it for the default CN=<host> leaf |
Pair an unconditional remove with a set when a token exists — the remove
is the anti-spoofing half of a minted header, explained in Credential
Injection.
Shaping the server certificate
Section titled “Shaping the server certificate”mtls is the identity wardcell presents upstream, to the origin. server_cert
is the other leg: the leaf wardcell presents downstream, to the sandbox client.
A client that does not use the system trust store — SPIFFE/SPIRE, Istio, a
private mesh identity system — verifies a server identity carried in the
certificate, so wardcell mints
a leaf carrying it and signs it under the per-run CA the plugin was handed at
wardcell.init. The client then trusts the chain
and finds the identity it expects.
{"jsonrpc": "2.0", "id": "0191e7…-4", "result": { "type": "credential", "mtls": { "cert_chain": "...", "private_key": "..." }, "server_cert": { "subject": "", "sans": [{"type": "dns", "value": "svc.us-east-1.internal.example.com"}], "extensions": [{"oid": "1.3.6.1.4.1.10949.3.1", "der": "<base64 DER>", "critical": false}], "not_after": "2026-10-06T19:00:00Z" }}}wardcell supplies the structure — a fresh key, the signature, and the structural
extensions (basic constraints, key usage, SKI, AKI) — and applies subject, the
typed sans (dns, uri for a SPIFFE ID, ip), and the extensions on top.
Each extension is an opaque OID and base64 DER: wardcell embeds the bytes without
interpreting them. not_after is optional.
How you build the spec is yours: from your own configuration, or by probing the
origin — dial ip:port (the connection-level resolve carries both) with the
provider’s SNI, read the certificate it presents, and copy the SANs and
extensions the client checks. The probe is the plugin’s, so nothing
provider-specific enters wardcell; the Go example ships a reusable one.
On an authenticated zone wardcell also requests an optional client certificate
and accepts any, including none — so a mutual-TLS client whose stub identity it
finds in $WARDCELL_PLUGIN_DIR completes its handshake,
while a plain client sends none and is unaffected. wardcell does not forward the
stub: the upstream leg uses the real identity from mtls.
This works when the client’s trust rests on a file or variable the plugin can point at wardcell’s CA, and its check on identity carried in the leaf. It cannot defeat true key pinning, a trust anchor the plugin cannot write, or data cryptographically bound to the origin’s key (embedded SCTs).
A further outcome is not a credential at all. When the plugin cannot answer, it says so with the protocol’s own error:
{"jsonrpc": "2.0", "id": "0191e7…-4", "error": {"code": -32000, "message": "the vault is unreachable"}}An unknown type or action, a set with no value, a remove with one, a
credential carrying none of mtls, a header or a server_cert, or anything
that is not JSON is a failure too, not a none. The request does not leave and
the caller gets 502.
Every bound wardcell puts on you
Section titled “Every bound wardcell puts on you”A plugin is consulted from inside a connection that is holding an admission permit, so “how long can a slow plugin stall” and “how long can it deny service to everything else” are the same question. The numbers exist because of that:
| 15 s | the whole of one resolution. Unlike an approval, this cannot wait indefinitely: nothing here is in front of a person, and a request with a client at the other end is waiting on it |
| 5 s | subtracted from expires_at when deciding whether a cached answer is still usable, so a connection is never opened with a credential that expires moments later |
| 1024 hosts | the cache’s size. Eviction is harmless — a miss asks again |
A resolution that runs out of budget fails; it does not degrade to none.
Caching
Section titled “Caching”wardcell caches the certificate (keyed by host) and the header set (keyed by the
whole request) independently, dropping each five seconds before its expiry.
headers_expires_at absent caches the set for the run, a past time re-asks every
request — the two values a per-request minted token and a static header want —
and a future time caches until then; none and failures are never cached. The
keys, the two clocks and the reasoning are in Credential
Injection.
Writing one
Section titled “Writing one”Start from an example — one in Rust, one in Go, each implementing every method with the transport and framing already written. Replace one function, in the file named for it:
pub fn credential_for(request: &CredentialRequest) -> Credential { // Look `request.host` up in your own configuration, fetch from your secret // store, and set the expiry so wardcell asks again in time for a rotation. // `request.is_for_a_request()` tells a per-request call (mint a token for // `request.method` / `request.path`) from a connection-time one. Credential::Present { mtls: None, headers: vec![ HeaderOp::Remove { name: "Authorization".to_string() }, HeaderOp::Set { name: "Authorization".to_string(), value: format!("Bearer {}", fetch_token_for(&request.host)), }, ], headers_expires_at: Some("2026-08-23T18:04:00Z".to_string()), }}Returning the same credential for every host is the one thing a real plugin must not do.
Testing one
Section titled “Testing one”Point WARDCELL_PLUGIN_SOCKET at a path, start the plugin, and talk to it
yourself before involving wardcell:
WARDCELL_PLUGIN_SOCKET=/tmp/plugin.sock ./my-plugin &printf '%s\n%s\n' \ '{"jsonrpc":"2.0","id":"1","method":"wardcell.init","params":{"run":"test","ca_cert":"","plugin_dir":"/tmp/plugin-dir"}}' \ '{"jsonrpc":"2.0","id":"2","method":"credentials.resolve","params":{"host":"api.example.com","zone":"user"}}' \ | nc -U /tmp/plugin.sockThe three failure paths worth exercising deliberately are none, an error, and
a plugin that accepts the question and never answers — the last is what the 15
second budget exists for, and it is the one people do not test.