Outbound: mTLS
This chapter covers the case where the application presents an X.509 client certificate during the outbound TLS handshake to a downstream service that requires mTLS. The credential is the application's workload identity in X.509 form, typically an X.509-SVID issued by SPIRE or an equivalent. The downstream validates the certificate against its trust anchor and accepts or rejects the connection.
The feature flag is outbound-mtls (off by default).
When to use it
Outbound mTLS is the right pattern for service-to-service traffic within a federation that uses mTLS as the standard authentication mechanism (a SPIFFE-based service mesh, an intra-organisation network where everything speaks mTLS, a partner integration where both sides have agreed to mTLS). The application's certificate identifies it as a workload to the downstream; no bearer token needs to ride the request.
The pattern is operationally simpler than outbound OAuth because the authentication happens once at connection setup rather than per request. A long-lived TLS connection handles many requests without re-authenticating; a short-lived connection re-authenticates on the next request. The cost is the TLS handshake's CPU and round-trip; the benefit is no per-request authentication state.
Configuration
OutboundMtlsClient is the type that holds the certificate and
key, and provides them to the outbound TLS handshake. The
configuration:
use axess::workload::outbound::{OutboundMtlsClient, OutboundMtlsConfig};
let client = OutboundMtlsClient::new(OutboundMtlsConfig {
client_cert_path: "/var/lib/axess/svid/cert.pem".into(),
client_key_path: "/var/lib/axess/svid/key.pem".into(),
ca_bundle_path: Some("/var/lib/axess/svid/ca.pem".into()),
reload_interval: Some(Duration::from_secs(300)),
});
client_cert_path and client_key_path are filesystem paths to
the certificate and the private key. The conventional location is
where SPIRE writes them: SPIRE rotates the certificate on a
configurable schedule (typically every few hours), writes the new
files atomically, and the client picks them up on next read.
ca_bundle_path is the optional path to the trust anchor for the
downstream's server certificate. When set, the client validates
the downstream's server cert against this bundle; when unset, the
client uses the system trust store.
reload_interval controls how often the client checks the
certificate files for changes. The check is a stat call; an
unchanged file is a no-op, a changed file triggers a re-read. The
default (every five minutes) matches typical SPIRE rotation
schedules; deployments with faster rotation lower this.
The TLS handshake
The client integrates with the application's HTTP client (typically
reqwest, but the pattern generalises) through a custom
Connector:
use axess::workload::outbound::OutboundMtlsClient;
use reqwest::Client;
let mtls = OutboundMtlsClient::new(/* ... */);
let http_client = Client::builder()
.use_preconfigured_tls(mtls.rustls_client_config())
.build()?;
let response = http_client
.get("https://downstream.example/data")
.send()
.await?;
rustls_client_config returns a rustls ClientConfig with the
certificate, key, and trust anchor configured. The
use_preconfigured_tls integration on reqwest accepts this
directly; other HTTP clients have similar integration points.
The handshake validates the downstream's server certificate against the configured trust anchor (or the system store), then presents the client certificate. If the downstream requires the client certificate and the application's certificate is missing or invalid, the handshake fails. If the downstream does not require the certificate, the handshake succeeds and the certificate is ignored.
Certificate rotation
The certificate rotation is what makes outbound mTLS sustainable in production. A static certificate provisioned at deployment time expires; the deployment has to redeploy to refresh it. A rotated certificate refreshes itself; the deployment runs indefinitely.
SPIRE rotates X.509-SVIDs on a schedule the operator configures
(typically every few hours). The new certificate is written
atomically to the filesystem (a temporary file plus a rename, so
the in-progress reads see either the old or the new, never a
truncated file). The application's OutboundMtlsClient reads the
files at construction and on its reload interval.
The reload-interval choice matters. Too short, and the client spends CPU on stat calls. Too long, and the client uses an expired certificate, producing handshake failures. The recommendation is to set the interval to about a third of the certificate's lifetime, so a typical rotation leaves enough time for the next reload to pick up the new files before expiry.
A reload that finds a malformed certificate logs the error and keeps the previous certificate in memory. The client continues to function until the previous certificate expires, by which point either the malformed state is fixed or the handshake fails. The graceful-degradation pattern is the right shape: a botched rotation should not bring down the application immediately.
When the downstream is also axess
A common shape is two axess-instrumented services calling each
other over mTLS. The calling side presents its X.509-SVID through
the outbound-mtls machinery; the receiving side validates it
through the mtls resolver from Inbound: mTLS-SVID. The two
sides compose without any further integration: the same SPIFFE
identity flows through the TLS handshake, the receiving resolver
extracts it, the resulting principal is the calling service's
identity.
The pattern is what gives a SPIFFE-based deployment a fully identity-aware service mesh at the application layer, without requiring a sidecar proxy. The mesh's identity is the application's identity; the audit trail records the same identity at every hop.
Threat model
Outbound mTLS shares the threat model of the X.509-SVID inbound case from Inbound: mTLS-SVID. The key-storage problem is the biggest concern: a workload whose private key is on disk is vulnerable to filesystem compromise; a workload whose key lives in a TPM, HSM, or KMS is much harder to compromise.
The additional concern for outbound is the downstream's trust configuration. A misconfigured downstream that accepts any client certificate from any CA (or that does not require client certificates at all) defeats the authentication. The defence is operational: ensure the downstream's trust configuration is correct, monitor for unexpected accepted connections, audit the configuration on a schedule.
Troubleshooting
If the handshake fails with a certificate-validation error, the downstream does not trust the application's CA. The downstream's trust bundle needs to include the application's CA; this is the downstream's configuration, not the client's.
If the handshake succeeds but the downstream returns 401 on every request, the downstream is performing authorisation against the certificate's identity rather than just authentication. Check the downstream's authorisation policy: it may require a specific SPIFFE path, a specific issuer, or a specific X.509 extension that the application's certificate does not have.
If the reload fails silently and the application uses an expired certificate, check the reload-interval configuration and the application's log output. The reload errors are logged at warn level; a missed reload typically surfaces as a "failed to read certificate" message.
Further reading
Inbound: mTLS-SVID covers the receiving side of the same machinery. Workload identity overview covers the SPIFFE model both sides use. Cloud STS exchange covers the alternative pattern for downstreams that require bearer tokens rather than mTLS. Operations runbook covers the certificate rotation and the key-storage choices for production deployments.