Skip to content

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.

/etc/wardcell/config.toml
[plugin]
path = "/usr/local/libexec/wardcell/wardcell-plugin"
zones.toml
[zones.user]
profile = "developer"
authenticated = true

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

  1. wardcell picks a socket path and execs the plugin once per run, with WARDCELL_PLUGIN_SOCKET set to that path. Nothing else is passed.
  2. The plugin binds and listens on that path. wardcell is the client.
  3. wardcell retries connecting with backoff for about ten seconds, then sends the wardcell.init handshake — 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.
  4. 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.
  5. The plugin is killed when the run ends.

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.

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"
}}
nonethis host needs nothing. The request proceeds unauthenticated — a normal outcome, and never cached, so wardcell asks again on the next connection
mtlsthe 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.upstreamhow 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
headersa 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_atthe 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_certthe 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.

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.

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 sthe 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 ssubtracted 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 hoststhe cache’s size. Eviction is harmless — a miss asks again

A resolution that runs out of budget fails; it does not degrade to none.

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.

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:

credentials.rs
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.

Point WARDCELL_PLUGIN_SOCKET at a path, start the plugin, and talk to it yourself before involving wardcell:

Terminal window
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.sock

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