Mutual TLS & Service-to-Service Security

Why mTLS beats API keys/JWTs for zero-trust service auth, with a complete setup in Spring Boot, ASP.NET Core, and Go.

Regular TLS only verifies one direction

When a browser hits https://api.example.com, TLS proves the server's identity to the client — the browser checks the server's certificate against a trusted CA and gets confidence it's really talking to api.example.com, not an impostor. It proves nothing about the client — the server has no cryptographic idea who's calling it, only what the request claims (a bearer token, an API key). For service-to-service calls inside a microservices system, that gap matters a lot more than it does for a public API: any process that can reach inventory-service on the network can call it, and a stolen or forwarded credential is enough to impersonate a legitimate caller.

Mutual TLS (mTLS) closes that gap by requiring both sides to present a certificate during the TLS handshake, and both sides verify the other's certificate against a trusted CA before any application data is exchanged. Neither side has to trust anything the other one merely says about its identity — the identity is proven cryptographically as part of establishing the connection itself, which is exactly the assumption a zero-trust network architecture is built on: no service is trusted just because it's "inside the network perimeter," every connection authenticates itself regardless of where it originates.

Text
Regular TLS:                          Mutual TLS:
Client ---- verifies server cert ---> Server    Client <--- verifies server cert ---- Server
Client <----- (no verification) ----- Server    Client --- server verifies client cert -> Server

mTLS vs API keys/JWTs for service-to-service auth

mTLS API keys JWTs
What's actually verified Both sides prove possession of a private key matching a CA-signed certificate, as part of the TLS handshake itself A shared secret string, checked by the receiving service against a stored value A signed, usually short-lived token, typically issued by a central identity provider/STS
Layer Transport layer — happens before any application request is even sent Application layer — sent as a header on every request Application layer — sent as a header on every request
Compromise if intercepted Ciphertext only — the private key itself never goes over the wire, so intercepting traffic doesn't hand over the credential The key itself is the credential; anyone who obtains it can use it directly The token itself is the credential; anyone who obtains an unexpired one can use it directly
Revocation CRL/OCSP checks, or (far more commonly today) simply issuing very short-lived certificates that expire on their own Requires a database lookup or an explicit blocklist Must wait for expiry, or maintain a blocklist/introspection endpoint
Setup complexity Higher — needs a CA, certificate issuance, and a rotation story for every service Lowest — mint a string, store it, check it Medium — an issuer, signing keys, and validation logic on every receiver
Typical rollout at scale A service mesh (Istio, Linkerd) automates issuance and rotation for the whole fleet Hand-rolled, per application Hand-rolled, or delegated to an API gateway/OAuth2 provider
Best fit Zero-trust internal networks, regulated environments, defense against a compromised internal host Simple internal tools, low-stakes services, quick prototypes Carrying a user's delegated identity through a call chain (OAuth2-style), not just proving which service is calling

The three aren't always mutually exclusive — a common pattern is mTLS proving which service is calling (transport-level), with a JWT riding inside that already-authenticated connection to carry which user the call is acting on behalf of.

A complete mTLS setup

The same scenario across all three stacks: inventory-service requires and verifies a client certificate on every incoming connection, and orders-service presents its own client certificate when calling inventory-service. All certificates in these examples are issued by one internal CA (internal-ca.pem) that both services trust.

Spring Boot

inventory-service's application.yml requiring and verifying client certificates:

YAML
server:
  port: 8443
  ssl:
    enabled: true
    client-auth: need
    key-store: classpath:inventory-service-keystore.p12
    key-store-password: ${KEYSTORE_PASSWORD}
    key-store-type: PKCS12
    trust-store: classpath:internal-ca-truststore.p12
    trust-store-password: ${TRUSTSTORE_PASSWORD}
    trust-store-type: PKCS12

server.ssl.client-auth: need is what turns on plain TLS's default one-way trust into mutual TLS — need rejects the handshake outright if the caller doesn't present a certificate signed by something in trust-store (want would only request one optionally, which silently allows unauthenticated callers through — see Common mistakes).

orders-service calling inventory-service with its own client certificate, via WebClient:

Java
@Bean
public WebClient inventoryServiceClient() throws Exception {
    KeyStore keyStore = KeyStore.getInstance("PKCS12");
    keyStore.load(new ClassPathResource("orders-service-keystore.p12").getInputStream(), "changeit".toCharArray());
    KeyManagerFactory keyManagerFactory = KeyManagerFactory.getInstance(KeyManagerFactory.getDefaultAlgorithm());
    keyManagerFactory.init(keyStore, "changeit".toCharArray());

    KeyStore trustStore = KeyStore.getInstance("PKCS12");
    trustStore.load(new ClassPathResource("internal-ca-truststore.p12").getInputStream(), "changeit".toCharArray());
    TrustManagerFactory trustManagerFactory = TrustManagerFactory.getInstance(TrustManagerFactory.getDefaultAlgorithm());
    trustManagerFactory.init(trustStore);

    SslContext sslContext = SslContextBuilder.forClient()
        .keyManager(keyManagerFactory)     // presents orders-service's own certificate to inventory-service
        .trustManager(trustManagerFactory) // and verifies inventory-service's certificate against the internal CA
        .build();

    HttpClient httpClient = HttpClient.create().secure(spec -> spec.sslContext(sslContext));

    return WebClient.builder()
        .baseUrl("https://inventory-service:8443")
        .clientConnector(new ReactorClientHttpConnector(httpClient))
        .build();
}

ASP.NET Core

Kestrel configured in Program.cs to require and verify a client certificate:

C#
builder.WebHost.ConfigureKestrel(options =>
{
    options.ConfigureHttpsDefaults(https =>
    {
        https.ClientCertificateMode = ClientCertificateMode.RequireAndVerifyCertificate;
    });
});

builder.Services.AddAuthentication(CertificateAuthenticationDefaults.AuthenticationScheme)
    .AddCertificate(options =>
    {
        options.AllowedCertificateTypes = CertificateTypes.Chained;
        options.RevocationMode = X509RevocationMode.NoCheck; // short-lived certs instead of CRL checks — see rotation below
        options.Events = new CertificateAuthenticationEvents
        {
            OnCertificateValidated = context =>
            {
                if (context.ClientCertificate.Issuer != "CN=NoaLabs Internal CA")
                {
                    context.Fail("certificate was not issued by the internal CA");
                    return Task.CompletedTask;
                }
                context.Success();
                return Task.CompletedTask;
            }
        };
    });

var app = builder.Build();
app.UseAuthentication();
app.UseAuthorization();

orders-service attaching its own client certificate to outgoing calls, via HttpClientHandler:

C#
var handler = new HttpClientHandler();
var clientCertificate = new X509Certificate2("orders-service-client.pfx", clientCertPassword);
handler.ClientCertificates.Add(clientCertificate);
handler.ClientCertificateOptions = ClientCertificateOption.Manual;

builder.Services.AddHttpClient("InventoryService", client =>
{
    client.BaseAddress = new Uri("https://inventory-service:8443");
})
.ConfigurePrimaryHttpMessageHandler(() => handler);

Go

Server side — inventory-service requiring and verifying client certificates:

Go
caCert, err := os.ReadFile("internal-ca.pem")
if err != nil {
    log.Fatal(err)
}
caCertPool := x509.NewCertPool()
caCertPool.AppendCertsFromPEM(caCert)

serverCert, err := tls.LoadX509KeyPair("inventory-service-cert.pem", "inventory-service-key.pem")
if err != nil {
    log.Fatal(err)
}

server := &http.Server{
    Addr:    ":8443",
    Handler: reservationsHandler(),
    TLSConfig: &tls.Config{
        Certificates: []tls.Certificate{serverCert},
        ClientAuth:   tls.RequireAndVerifyClientCert, // reject the handshake if no valid client cert is presented
        ClientCAs:    caCertPool,
    },
}

log.Fatal(server.ListenAndServeTLS("", "")) // cert/key already supplied via TLSConfig above

Client side — orders-service presenting its own certificate when calling inventory-service:

Go
clientCert, err := tls.LoadX509KeyPair("orders-service-client-cert.pem", "orders-service-client-key.pem")
if err != nil {
    log.Fatal(err)
}

caCert, err := os.ReadFile("internal-ca.pem")
if err != nil {
    log.Fatal(err)
}
caCertPool := x509.NewCertPool()
caCertPool.AppendCertsFromPEM(caCert)

httpClient := &http.Client{
    Transport: &http.Transport{
        TLSClientConfig: &tls.Config{
            Certificates: []tls.Certificate{clientCert}, // presented to inventory-service during the handshake
            RootCAs:      caCertPool,                    // used to verify inventory-service's own certificate
        },
    },
}

resp, err := httpClient.Post("https://inventory-service:8443/api/reservations", "application/json", body)

Service meshes: rolling mTLS out without every service implementing it

Every example above requires each service's team to manage keystores, truststores, and certificate loading correctly — doable, but it doesn't scale cleanly to a fleet of fifty services, and one team getting it subtly wrong (skipping hostname verification, using want instead of need) quietly reopens the gap for everyone. A service mesh (Istio, Linkerd) solves this by running a sidecar proxy alongside every service instance; the proxies handle certificate issuance, rotation, and the mTLS handshake between each other transparently, so an application's own code just makes a plain HTTP call to localhost and the sidecar upgrades it to mTLS on the wire. This is why most organizations running mTLS at scale reach for a mesh rather than having every service reimplement the examples above by hand — it turns "every team must get this right" into "the platform team configures it once."

Common mistakes

  • Setting client-auth: want (Spring) or ClientCertificateMode.AllowCertificate (ASP.NET Core) instead of need/RequireAndVerifyCertificate — these optional modes silently accept connections with no client certificate at all, which defeats the entire purpose while looking, at a glance, like mTLS is enabled.
  • Verifying only that a client certificate was presented and skipping the issuer/CA check — any certificate, including a self-signed one or one issued by an unrelated CA, would pass a check that only asks "is there a certificate," rather than "is there a certificate signed by our CA."
  • Letting certificates expire unnoticed across many services at once — because certificates for an entire fleet are often provisioned around the same time, an unmonitored expiry date becomes a synchronized, fleet-wide outage instead of one service's problem.
  • Reimplementing certificate loading and validation by hand in every service once the fleet has grown past a handful of services — this is exactly the point at which a service mesh's automated issuance and rotation stops being a "nice to have" and starts being the only maintainable option.