mTLS: Mutual TLS Between Services Without a Service Mesh

Both ends prove who they are, and you don't need Istio to do it

Ordinary TLS proves the server’s identity to the client. The browser checks the certificate, sees a name it trusts, and gets on with the conversation. The server, meanwhile, has no idea who the client is — it’ll talk to anyone. For service-to-service traffic inside your own infrastructure that’s backwards. You frequently care far more about which client is calling than the client cares about the server. Mutual TLS fixes that: both ends present certificates, both ends verify, and an unauthenticated caller never gets past the handshake.

The usual advice is “deploy a service mesh.” If you’re already running Kubernetes at scale, fine. But for a handful of services on a few VMs, dragging in Istio or Linkerd to get mTLS is using a crane to hang a picture. You can do it with a private CA and a few config lines.

Advertisement

mTLS is built on a private certificate authority. Every service gets a certificate signed by your CA, and every service is configured to trust only that CA. A certificate signed by anyone else — including the public web PKI — is rejected. This is the inversion that makes it secure: there’s no path for an outsider to obtain a certificate your services will accept.

I use cfssl or step-ca for this; for a small setup, plain openssl is enough to understand what’s happening. First the CA:

# Root CA key and self-signed cert (10 years)
openssl req -x509 -newkey rsa:4096 -nodes \
  -keyout ca.key -out ca.crt -days 3650 \
  -subj "/CN=internal-ca"

# A server cert request for the api service
openssl req -newkey rsa:2048 -nodes \
  -keyout api.key -out api.csr \
  -subj "/CN=api.internal"

# Sign it with the CA, valid 1 year
openssl x509 -req -in api.csr \
  -CA ca.crt -CAkey ca.key -CAcreateserial \
  -out api.crt -days 365

Repeat the last two steps for each client, changing the CN. The CN (or better, a SAN) becomes the identity you can authorise on later.

The cleanest place to require client certs is often the reverse proxy that already fronts the service. nginx does mTLS with two directives — point it at the CA bundle and set verification to on:

server {
    listen 443 ssl;
    server_name api.internal;

    ssl_certificate     /etc/ssl/api.crt;
    ssl_certificate_key /etc/ssl/api.key;

    # Require and verify the client certificate
    ssl_client_certificate /etc/ssl/ca.crt;
    ssl_verify_client      on;
    ssl_verify_depth       1;

    location / {
        # Pass the verified client identity upstream
        proxy_set_header X-Client-CN $ssl_client_s_dn_cn;
        proxy_pass http://127.0.0.1:8080;
    }
}

With ssl_verify_client on, nginx terminates any connection that doesn’t present a certificate signed by your CA — before a single byte reaches the application. The $ssl_client_s_dn_cn variable hands the verified Common Name to your backend, so the app can do authorisation (“is this service allowed to call that endpoint?”) on top of the authentication nginx already did.

The whole point is that a normal request fails. Confirm it:

# No client cert: rejected
$ curl https://api.internal/health
curl: (56) OpenSSL/3.0 error: tlsv13 alert certificate required

# With a valid client cert: through
$ curl --cert client.crt --key client.key \
       --cacert ca.crt https://api.internal/health
{"status":"ok"}

If the first command succeeds, your verification isn’t actually on — check that the proxy, not just the application, is enforcing it.

Issuing certificates is the easy 20%. The hard 80% is what happens when they expire — and they will, all at once, at 3am, because you set them all to 365 days on the same afternoon. A certificate that lapses takes the service down as surely as a crashed process, and a stale CRL means a compromised cert keeps working.

This is the genuine argument for tooling. step-ca can run as an online CA with short-lived certificates (hours, not months) and automated renewal, so rotation becomes the default rather than an emergency. If you stay with static OpenSSL certs, you must have monitoring on expiry dates and a documented reissue procedure. “We’ll remember” is how you find out, in production, that you won’t.

For a small estate — say, fewer than a dozen services on a handful of hosts — absolutely. You get strong, cryptographic service identity for the cost of a CA and some proxy config, and you avoid the operational weight of a mesh sidecar on every pod. The break-even point is rotation: once you have enough services that manual renewal is a chore, adopt step-ca or similar for automated short-lived certs. Reach for a full service mesh only when you also want traffic shifting, retries, and observability baked in — mTLS alone does not justify it.

Advertisement

Related Content

Advertisement
Smarc
Written by Smarc

Founder and editor of vo.rs. A lifelong tinkerer who self-hosts far more than is sensible, hardens Linux boxes for fun, and prods the latest AI tools to see what they can really do. The how-to guides here are the notes Smarc wishes had existed the first time round.