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 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::workload::PeerCertChain;

async fn mtls_middleware<B>(
    mut req: Request<B>,
    next: Next<B>,
) -> Response {
    if let Some(chain) = extract_cert_from_terminator(&req) {
        req.extensions_mut().insert(PeerCertChain::from(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.

The resolver

MtlsResolver is the resolver that reads the chain from the extensions, extracts the SPIFFE URI, validates against the configured trust domain, and produces a Principal::Workload.

use axess::workload::{MtlsResolver, MtlsResolverConfig};

let resolver = MtlsResolver::new(MtlsResolverConfig {
    trust_domain: "prod.example.com".parse().unwrap(),
    tenant_resolver: Box::new(MyTenantResolver::new(/* ... */)),
});

The configuration is small because most of the validation work has already happened. The terminator validated the certificate chain; the resolver only needs to read the SAN URI, parse it as a SPIFFE ID, and check that the trust domain matches the configured one.

tenant_resolver is the adopter-supplied piece that maps the SPIFFE path to a TenantId. The path typically follows a convention like /svc/<service>/<tenant_slug>, and the resolver looks up the tenant id from the slug. The convention is the deployment's; axess just provides the trait surface.

The validation flow

The resolver's resolve method runs five steps.

The first step is reading the peer certificate chain from request extensions. Absence here is a configuration error (the extraction middleware did not run), and the resolver returns MtlsError::NoPeerCert.

The second step is parsing the leaf certificate. The chain may contain intermediate certificates; the leaf is the first one. The resolver extracts the SAN extension and looks for a URI value matching the SPIFFE format. Absence of a SPIFFE URI in the SAN produces MtlsError::NoSpiffeId.

The third step is parsing the SPIFFE URI. The URI must be well-formed (a spiffe:// scheme, a trust domain, a path). A malformed URI produces MtlsError::MalformedSpiffeId.

The fourth step is the trust-domain match. The parsed trust domain must equal the configured one. A mismatch produces MtlsError::TrustDomainMismatch.

The fifth step is the tenant resolution. The path is fed to the configured TenantResolver, which returns a TenantId. The resolver assembles the WorkloadPrincipal with the SPIFFE id, the trust domain, the issuer (Issuer::Mtls), and the tenant id, and returns it.

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 */ },
})

The attributes map carries any X.509 fields the deployment chooses to surface (the certificate's serial number for audit, the certificate's expiry for short-lived-cert tracking, custom extensions). The choice is the deployment's; the resolver exposes the chain so the adopter can read what they need.

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 resolver returns NoPeerCert for connections that should work, the extraction middleware is not running, or the terminator is not forwarding the certificate. Inspect the request extensions before the resolver runs.

If the resolver returns NoSpiffeId, the certificate does not carry a SPIFFE URI in the SAN. Inspect the certificate (openssl x509 -in cert.pem -text) to see what SAN entries are present. The issuer's configuration may need to be updated to include the SPIFFE URI.

If the resolver returns TrustDomainMismatch, a workload from a different trust domain has connected. If this is intentional, configure federation (covered in Inbound: federation).

If the resolver succeeds but the tenant resolution fails, the path convention is not matching the workload's actual SPIFFE path. Inspect the path and update the tenant resolver to handle the actual format.

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.