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.
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:
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:
@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:
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:
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:
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:
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) orClientCertificateMode.AllowCertificate(ASP.NET Core) instead ofneed/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.