Inbound: SPIFFE X.509-SVID via mTLS
A workload authenticates over mTLS by presenting a leaf X.509
certificate that carries its SPIFFE identity in a Subject
Alternative Name URI. The TLS handshake validates the certificate
against the trust-domain CA bundle, the application reads the
SPIFFE URI from the SAN, and the resulting identity becomes a
Principal::Workload. The mechanism is the right choice for
service-to-service traffic where mTLS is already in place (a
service mesh, a load balancer that preserves client certs, a
direct VPC peering).
The feature flag is mtls (off by default).
The credential
An ordinary X.509 certificate, with the identity in one SAN entry.
The credential shape
An X.509-SVID is an ordinary X.509 leaf certificate with one
specific requirement: the Subject Alternative Name extension
contains a URI of the form spiffe://<trust_domain>/<path>. The
certificate is otherwise standard; deployments may put additional
information in the subject DN, the other SAN entries, or X.509
extensions, but the SPIFFE URI is the identity the resolver reads.
The certificate chain is signed by the trust domain's CA. The chain validates the certificate's authenticity; the SAN URI identifies the workload within the trust domain.
Where the certificate comes from
Axess does not handle the TLS handshake. The handshake happens where TLS terminates (rustls in the application process, a sidecar proxy in a service mesh, a load balancer in front of the application). The terminator validates the certificate chain against the configured CA bundle, accepts or rejects the connection, and on acceptance makes the certificate available to the application.
The mechanism for making the certificate available depends on the
terminator. For rustls in process, the certificate is available
through axum_server::tls_rustls::RustlsConnectInfo or an
equivalent connector callback, which the resolver wires through
directly. For a sidecar proxy (Istio, Linkerd, Envoy in a service
mesh), the proxy forwards the certificate as a header (Istio uses
X-Forwarded-Client-Cert, Linkerd uses l5d-client-id), and the
resolver wires through a small adapter that parses the header
into a certificate. For a load balancer in passthrough TLS mode,
rustls handles the validation in-process; for a load balancer in
mTLS-terminating mode (AWS ALB with mTLS, Cloudflare with
client-cert auth, nginx with ssl_verify_client), the load
balancer forwards the certificate in a header whose name and
format depend on the product.
The application's job is to extract the certificate chain from
wherever the terminator put it, wrap it in PeerCertChain, and
insert it into the request extensions before the resolver runs.
use axess::federation::mtls::PeerCertChain;
async fn mtls_middleware<B>(
mut req: Request<B>,
next: Next<B>,
) -> Response {
// `chain` is a `Vec<CertificateDer<'static>>`, leaf first.
if let Some(chain) = extract_cert_from_terminator(&req) {
req.extensions_mut().insert(PeerCertChain::new(chain));
}
next.run(req).await
}
The critical detail: the extraction must trust only sources the
deployment trusts. A request that arrives directly to the
application with a forged X-Forwarded-Client-Cert header must
not be accepted. Either run the application on a socket the
terminator owns and reject direct connections at the network
layer, or gate the header on a token the terminator injects
alongside the certificate.
Wiring it up
Reading the tenant out of the leaf comes first, and that read validates nothing.
The resolver
MtlsResolver reads the SPIFFE URI out of the leaf certificate,
checks it against the configured trust domain, and produces a
Principal::Workload.
use axess::federation::mtls::{MtlsResolver, PeerCertChain, peek_spiffe};
use axess_identity::PrincipalResolver;
// Which tenant this is, from the SPIFFE ID, before the resolver is
// built: `peek_spiffe` is a plain function over the leaf certificate
// and does no validation beyond parsing the SAN URI.
let leaf = chain.leaf().ok_or(MtlsError::EmptyChain)?;
let components = peek_spiffe(leaf)?;
let tenant_id = my_directory.tenant_for(&components.tenant_slug)?;
let resolver = MtlsResolver::from_chain(
&chain,
"prod.example.com".parse()?,
tenant_id,
)?;
let principal = resolver.resolve().await?; // Principal::Workload
from_chain takes the leaf and returns MtlsError::EmptyChain if
there is none. Where you already hold a leaf, from a terminator that
hands you one certificate rather than a chain, MtlsResolver::new takes it
directly and is infallible.
Either way the resolver holds the leaf, the trust domain it accepts,
and the tenant. There is no configuration struct and no tenant-resolver
trait: mapping a SPIFFE path to a TenantId is the adopter's, done
before construction with peek_spiffe, because the convention is the
deployment's.
The work is small because most of the validation already happened: the terminator validated the chain, and the resolver parses the SAN URI and checks the trust domain.
The validation flow
Two error types are in play, and which one you see depends on where you are standing.
peek_spiffe is the parsing step, and it reports MtlsError. It
parses the leaf's DER, where a failure is MtlsError::CertParse, then
reads the Subject Alternative Name extension. A certificate with no
SAN yields MtlsError::NoSan; one whose SAN carries no spiffe://
URI yields MtlsError::NoSpiffeUri. The URI is then parsed as a
SPIFFE ID and decomposed into
spiffe://<trust_domain>/<service>/<tenant_slug>; a URI that is
malformed, or whose path does not match that shape, yields
MtlsError::Identity, which carries the underlying IdentityError.
MtlsError::EmptyChain comes from MtlsResolver::from_chain rather
than from parsing, and means the chain held no leaf at all.
resolve is the PrincipalResolver step, and it reports
IdentityError, because that is what the trait returns for every
resolver. It re-runs peek_spiffe on the leaf, cheaply and
deliberately, so the cryptographic claim flows through one path rather
than through whatever the middleware peeked at earlier, then compares
the presented trust domain against the configured one. A mismatch is
IdentityError::InvalidSpiffeId, naming both domains, and is logged at
warn. Every other parse failure collapses to
IdentityError::NotAuthenticated, with the specific MtlsError logged
at debug.
That collapse is deliberate: a caller presenting a bad certificate
learns only that it was rejected, while the operator reading the logs
learns which of CertParse, NoSan or NoSpiffeUri it was. When you
want the distinction in your own code, call peek_spiffe yourself,
which the tenant lookup means you are doing anyway.
The resolver does not resolve tenants. The tenant is
decided before it is built, and resolve copies the TenantId it was
given into the principal.
What the principal looks like
A successful validation produces:
Principal::Workload(WorkloadPrincipal {
workload_id: WorkloadId::new("spiffe://prod.example.com/svc/billing/tenant-acme"),
trust_domain: TrustDomain::new("prod.example.com"),
issuer: Issuer::Mtls,
tenant_id: TenantId::parse("acme").unwrap(),
tenant_slug: "acme".into(),
service_name: "billing".into(),
attributes: { /* X.509 fields the deployment exposes */ },
})
attributes is always empty here. MtlsResolver puts nothing in it,
and the certificate is not carried on the principal, so X.509 detail
you want downstream (the serial number for audit, the expiry for
short-lived-cert tracking, a custom extension) has to be read from
the leaf in your own middleware and carried in your own request
extension. The field exists on WorkloadPrincipal for resolvers that
do populate it from claims.
Combining with other resolvers
A common shape is mTLS as the transport-level proof of identity plus a session cookie or a JWT as the application-level proof of who the user behind the workload is. The two layers compose: the mTLS resolver runs first and establishes the workload's identity; the session or JWT layer runs second and establishes the human's identity inside the workload. Cedar policies can match on both.
The composition is what gives a deployment "the calling service is authenticated AND the user inside the call is authenticated", which is the right shape for delegated workflows. Delegated and OBO access covers the pattern from the OBO side.
Threat model
mTLS is robust against the standard attacks when the issuing CA is secure.
Against token theft: there is no token. The credential is a private key the workload holds; an attacker without the key cannot present the certificate.
Against in-flight tampering: the TLS layer protects against it. The certificate is bound to the TLS session; an attacker on the wire cannot substitute a different certificate without breaking the handshake.
Against replay: the certificate is short-lived (SPIRE typically rotates SVIDs every few hours) and bound to a TLS session. Replay across sessions requires the private key, which the attacker does not have.
The remaining attack surface is the issuing CA. A compromised CA can issue compromised certificates, and the validation cannot detect it. The defence is operational: secure the issuing CA, monitor the issuance log, rotate the CA's signing key on a schedule.
The other remaining surface is the workload's private-key storage. A workload that stores its key in a file on disk is vulnerable to file-system compromise; a workload that stores its key in a hardware enclave (TPM, HSM, KMS) is much harder to compromise. SPIRE supports both shapes through its workload-API attestation; the choice is the deployment's.
Troubleshooting
If the chain is empty (EmptyChain, or PeerCertChain::leaf
returning None) for connections that should work, the terminator
is not requesting a client certificate, or your middleware is not
recording the one it received. Inspect what the terminator reports
before the resolver runs.
If resolve returns IdentityError::NotAuthenticated, the debug log
carries the real reason. NoSan or NoSpiffeUri means the certificate
does not carry a SPIFFE URI in its Subject Alternative Name. Inspect
it with openssl x509 -in cert.pem -text to see what SAN entries are
present, and update the issuer's configuration to include the SPIFFE
URI. CertParse means the bytes are not a certificate at all, which
usually means the middleware picked up the wrong header or forwarded a
PEM where DER was expected.
If resolve returns IdentityError::InvalidSpiffeId, read the
message. "trust domain mismatch" means a workload from another trust
domain connected; if that is intentional, see Inbound: federation.
Anything else means the SPIFFE path does not have the
/<service>/<tenant_slug> shape axess decomposes, and the issuer's
path convention needs to change, because axess does not make the shape
configurable.
If peek_spiffe succeeds but your own tenant lookup then fails, the
tenant_slug in the path is not one your directory knows. That is
your mapping to fix, not axess's; the resolver never sees it.
Further reading
Workload identity overview covers the SPIFFE model and the
unified Principal type. Inbound: JWT-SVID covers the bearer
token variant for deployments where mTLS is impractical. Inbound:
federation covers cross-trust-domain patterns. mTLS-based
authentication in Part III covers mTLS for human authentication;
the validation mechanics are the same, but the interpretation of
the certificate differs.