mTLS from Scratch in Go and Rust: Lock Down Your Services Without a Service Mesh

Most service-to-service auth on internal networks is theater. A shared secret in an env var. A JWT that never expires. An API key committed to the repo three years ago. The reasoning is usually "it’s internal anyway" — until it isn’t, and some compromised pod is laterally moving through your cluster talking to everything that’ll answer.

Mutual TLS flips the model. Both sides of a connection prove their identity with certificates signed by a trusted CA. Not headers. Not tokens. Cryptographic proof at the transport layer, before a single byte of application data is exchanged. That’s the zero-trust baseline and it requires exactly zero external dependencies to implement in Go or Rust.

This guide covers the whole thing from scratch: generating a CA, issuing server and client certificates, and writing the actual server and client code in both languages. No service mesh, no Istio, no Linkerd — just stdlib and one solid TLS crate.


How mTLS Actually Works

Standard TLS: the client verifies the server’s certificate. The server doesn’t care who the client is. It’s HTTPS — you just need to trust the server.

Mutual TLS adds the reverse leg. The server also demands a certificate from the client and verifies it against a CA it trusts. If the client can’t present a valid cert, the TLS handshake fails before the TCP connection hands off to the application.

The handshake looks like:

  1. Server sends its certificate.
  2. Client verifies it. Client sends its certificate.
  3. Server verifies it against its trusted CA store.
  4. Keys are derived. The encrypted session starts.

Both sides need a private key, a certificate signed by a CA the other party trusts, and the CA’s public certificate to verify the peer. That’s three files each — six total. Let’s generate them.


Certificate Setup

You need OpenSSL for this. If you’re on a modern system, it’s already there. This block creates a self-signed CA, then issues a server cert and a client cert from it.

# ── 1. Create the CA ────────────────────────────────────────────
openssl genrsa -out ca.key 4096
openssl req -new -x509 -days 3650 -key ca.key -out ca.crt \
  -subj "/CN=MyDev-CA/O=Demo"

# ── 2. Server certificate ────────────────────────────────────────
openssl genrsa -out server.key 2048
openssl req -new -key server.key -out server.csr \
  -subj "/CN=localhost"

# SAN extension is mandatory — CN alone is rejected by modern TLS stacks
openssl x509 -req -days 365 \
  -in server.csr -CA ca.crt -CAkey ca.key -CAcreateserial \
  -out server.crt \
  -extfile <(printf "subjectAltName=DNS:localhost,IP:127.0.0.1")

# ── 3. Client certificate ────────────────────────────────────────
openssl genrsa -out client.key 2048
openssl req -new -key client.key -out client.csr \
  -subj "/CN=service-a/O=Demo"

openssl x509 -req -days 365 \
  -in client.csr -CA ca.crt -CAkey ca.key -CAcreateserial \
  -out client.crt

You should now have: ca.key, ca.crt, server.key, server.crt, client.key, client.crt. Keep the .key files private. The .crt files are public — they get distributed.

Gotcha: SAN vs CN. RFC 2818 (since 2000) says clients must use the Subject Alternative Name extension to verify hostnames, not the Common Name. Chrome dropped CN matching in 2017. Go’s TLS stack and rustls follow the spec strictly. Omit the -extfile line and your server cert will be rejected at runtime — not at generation time. You won’t see the error until the TLS handshake.


Go Implementation

Go’s standard library handles mTLS without any third-party packages. The crypto/tls package has everything you need.

Server

package main

import (
	"crypto/tls"
	"crypto/x509"
	"fmt"
	"log"
	"net"
	"net/http"
	"os"
)

func main() {
	// Load CA cert — used to verify the client's certificate
	caCert, err := os.ReadFile("ca.crt")
	if err != nil {
		log.Fatal("failed to read CA cert:", err)
	}
	caPool := x509.NewCertPool()
	if !caPool.AppendCertsFromPEM(caCert) {
		log.Fatal("failed to parse CA cert")
	}

	// Server's own identity
	serverCert, err := tls.LoadX509KeyPair("server.crt", "server.key")
	if err != nil {
		log.Fatal("failed to load server keypair:", err)
	}

	tlsCfg := &tls.Config{
		Certificates: []tls.Certificate{serverCert},
		ClientAuth:   tls.RequireAndVerifyClientCert, // the mTLS part
		ClientCAs:    caPool,
		MinVersion:   tls.VersionTLS13,
	}

	// Use tls.Listen so the pre-built config is used as-is
	ln, err := tls.Listen("tcp", ":8443", tlsCfg)
	if err != nil {
		log.Fatal(err)
	}
	defer ln.Close()

	mux := http.NewServeMux()
	mux.HandleFunc("/", handleRequest)

	srv := &http.Server{Handler: mux}
	log.Println("mTLS server listening on :8443")
	log.Fatal(srv.Serve(ln))
}

func handleRequest(w http.ResponseWriter, r *http.Request) {
	// r.TLS.PeerCertificates is populated only after a successful mTLS handshake
	if len(r.TLS.PeerCertificates) == 0 {
		http.Error(w, "no client cert", http.StatusUnauthorized)
		return
	}
	cn := r.TLS.PeerCertificates[0].Subject.CommonName
	fmt.Fprintf(w, "Authenticated client: %s\n", cn)
}

The critical line is ClientAuth: tls.RequireAndVerifyClientCert. There are five levels in Go — NoClientCert, RequestClientCert, RequireAnyClientCert, VerifyClientCertIfGiven, and RequireAndVerifyClientCert. Only the last one both demands and cryptographically verifies the cert. Use anything else and you’re not doing mTLS — you’re pretending.

Client

package main

import (
	"crypto/tls"
	"crypto/x509"
	"fmt"
	"io"
	"log"
	"net/http"
	"os"
)

func main() {
	caCert, err := os.ReadFile("ca.crt")
	if err != nil {
		log.Fatal(err)
	}
	caPool := x509.NewCertPool()
	caPool.AppendCertsFromPEM(caCert)

	// Client's own certificate and key — presented during handshake
	clientCert, err := tls.LoadX509KeyPair("client.crt", "client.key")
	if err != nil {
		log.Fatal(err)
	}

	tlsCfg := &tls.Config{
		Certificates: []tls.Certificate{clientCert},
		RootCAs:      caPool,     // trust the server if it's signed by this CA
		MinVersion:   tls.VersionTLS13,
	}

	client := &http.Client{
		Transport: &http.Transport{TLSClientConfig: tlsCfg},
	}

	resp, err := client.Get("https://localhost:8443/")
	if err != nil {
		log.Fatal(err)
	}
	defer resp.Body.Close()

	body, _ := io.ReadAll(resp.Body)
	fmt.Println(string(body))
}

Both programs are ready to run. Start the server, then the client. You should see Authenticated client: service-a.

Gotcha: sharing the default http.Client. The zero-value http.Client uses a shared default transport with no TLS config. If you set http.DefaultTransport, you affect every HTTP call in the process, including any library that uses the default client. Always create an explicit http.Client with its own Transport.


Rust Implementation

Rust’s story is built around rustls — a pure-Rust TLS library that refuses to implement legacy cipher suites. No SSLv3, no RC4, no TLS 1.0/1.1. That’s a feature. Pair it with tokio-rustls for async support.

Add to Cargo.toml:

[dependencies]
tokio       = { version = "1", features = ["full"] }
tokio-rustls = "0.26"
rustls       = "0.23"
rustls-pemfile = "2"

Shared helpers

Both the server and client need to load PEM files. Put these in src/certs.rs or inline them:

use rustls::pki_types::{CertificateDer, PrivateKeyDer};
use rustls_pemfile::{certs, private_key};
use std::{fs::File, io::{self, BufReader}};

pub fn load_certs(path: &str) -> io::Result<Vec<CertificateDer<'static>>> {
    let mut reader = BufReader::new(File::open(path)?);
    certs(&mut reader).collect()
}

pub fn load_key(path: &str) -> io::Result<PrivateKeyDer<'static>> {
    let mut reader = BufReader::new(File::open(path)?);
    private_key(&mut reader)?
        .ok_or_else(|| io::Error::new(io::ErrorKind::InvalidData, "no private key found in file"))
}

Server

use rustls::{RootCertStore, ServerConfig, server::WebPkiClientVerifier};
use std::{io, sync::Arc};
use tokio::{io::{AsyncReadExt, AsyncWriteExt}, net::TcpListener};
use tokio_rustls::TlsAcceptor;

mod certs; // or inline the helpers above

#[tokio::main]
async fn main() -> io::Result<()> {
    // Load server identity
    let server_certs = certs::load_certs("server.crt")?;
    let server_key   = certs::load_key("server.key")?;

    // Build the CA pool used to verify connecting clients
    let mut root_store = RootCertStore::empty();
    for cert in certs::load_certs("ca.crt")? {
        root_store
            .add(cert)
            .map_err(|e| io::Error::new(io::ErrorKind::InvalidData, e))?;
    }

    // WebPkiClientVerifier enforces that the client presents a cert from our CA
    let client_verifier = WebPkiClientVerifier::builder(Arc::new(root_store))
        .build()
        .map_err(|e| io::Error::new(io::ErrorKind::InvalidData, e))?;

    let config = ServerConfig::builder()
        .with_client_cert_verifier(client_verifier)
        .with_single_cert(server_certs, server_key)
        .map_err(|e| io::Error::new(io::ErrorKind::InvalidData, e))?;

    let acceptor = TlsAcceptor::from(Arc::new(config));
    let listener = TcpListener::bind("0.0.0.0:8443").await?;
    eprintln!("mTLS server listening on :8443");

    loop {
        let (stream, peer_addr) = listener.accept().await?;
        let acceptor = acceptor.clone();

        tokio::spawn(async move {
            match acceptor.accept(stream).await {
                Ok(mut tls) => {
                    // Client is authenticated at this point — handshake succeeded
                    let body = "mTLS handshake successful.\n";
                    let resp = format!(
                        "HTTP/1.1 200 OK\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{}",
                        body.len(),
                        body
                    );
                    let _ = tls.write_all(resp.as_bytes()).await;
                }
                Err(e) => {
                    // This fires when client cert is missing or invalid
                    eprintln!("[{}] TLS handshake failed: {}", peer_addr, e);
                }
            }
        });
    }
}

Client

use rustls::{ClientConfig, RootCertStore, pki_types::ServerName};
use std::{io, sync::Arc};
use tokio::{io::{AsyncReadExt, AsyncWriteExt}, net::TcpStream};
use tokio_rustls::TlsConnector;

mod certs;

#[tokio::main]
async fn main() -> io::Result<()> {
    // Trust the server only if its cert is signed by our CA
    let mut root_store = RootCertStore::empty();
    for cert in certs::load_certs("ca.crt")? {
        root_store
            .add(cert)
            .map_err(|e| io::Error::new(io::ErrorKind::InvalidData, e))?;
    }

    let client_certs = certs::load_certs("client.crt")?;
    let client_key   = certs::load_key("client.key")?;

    let config = ClientConfig::builder()
        .with_root_certificates(root_store)
        .with_client_auth_cert(client_certs, client_key)
        .map_err(|e| io::Error::new(io::ErrorKind::InvalidData, e))?;

    let connector = TlsConnector::from(Arc::new(config));
    let tcp_stream = TcpStream::connect("127.0.0.1:8443").await?;

    let server_name: ServerName<'static> = ServerName::try_from("localhost")
        .map_err(|_| io::Error::new(io::ErrorKind::InvalidInput, "bad server name"))?
        .to_owned();

    let mut tls = connector.connect(server_name, tcp_stream).await?;

    tls.write_all(b"GET / HTTP/1.1\r\nHost: localhost\r\nConnection: close\r\n\r\n").await?;
    tls.flush().await?;

    let mut response = String::new();
    tls.read_to_string(&mut response).await?;
    print!("{}", response);

    Ok(())
}

Gotcha: rustls has no unsafe crypto escape hatch. If you’re integrating with a system that only speaks TLS 1.0 or uses deprecated cipher suites (some old Java services, some IoT hardware), rustls will refuse to connect. This is intentional. You’ll need native-tls as a fallback, which delegates to the OS TLS stack. For new services you control, rustls is the right call — the hard refusal is a feature, not a limitation.


Test It

Verify both implementations against each other and with curl:

# Test the server with curl — provide client cert and trust the CA
curl --cert client.crt --key client.key --cacert ca.crt https://localhost:8443/

# Confirm the server rejects a client without a cert (should get a TLS alert)
curl --cacert ca.crt https://localhost:8443/

The second curl should fail hard at the TLS layer — curl: (35) error:... certificate required. That’s correct behavior. If it returns an HTTP error instead, your ClientAuth isn’t set to RequireAndVerifyClientCert — the handshake succeeded without a client cert, which means you’re not running mTLS.


Gotchas

Clock skew kills cert validation. TLS certificates have notBefore and notAfter fields validated strictly. A server or client with a clock more than a few minutes off will get certificate is not yet valid or certificate has expired on certs that look fine to everyone else. In containerized environments, check that NTP is running on the host, not just the container. If you’re seeing intermittent TLS failures across nodes, check timedatectl status before anything else.

Certificate chains must be ordered correctly. When the CA has an intermediate certificate, the PEM file the server presents must include the full chain: leaf cert → intermediate → root, concatenated in that order. Many people dump them in the wrong order and then wonder why certain clients accept the connection while others reject it.

Private keys that are world-readable. After running those openssl commands, check ls -la *.key. They should be 600. If they’re 644, any process on the box can read them. In production, use a secrets manager (Vault, AWS Secrets Manager, Kubernetes Secrets with RBAC) — never commit private keys or leave them readable to all.

Letting certs expire. A self-signed CA cert with 3650 days and a server cert with 365 days that nobody’s watching is a production incident waiting to happen. Export cert expiry as a metric. A simple Prometheus scraper, a cron job running openssl x509 -enddate -noout -in server.crt, or cert-manager with automatic renewal are all valid. The rotation should be automated; a calendar reminder is not a monitoring strategy.

Forgetting to reload certs on rotation. Go’s tls.LoadX509KeyPair reads from disk once. If you rotate certs and send SIGHUP, nothing happens by default — the process is still holding the old cert in memory. Use tls.Config.GetCertificate with a callback that reloads from disk, or wrap cert loading behind a mutex and watch the filesystem with fsnotify. Rustls has the same issue — the Arc<ServerConfig> is immutable; rotating means building a new config and hot-swapping it.


Production-Ready Notes

Use a proper PKI. OpenSSL commands are fine for development and internal tooling, but in production you want Vault PKI or cert-manager. Both give you short-lived certificates (24–72 hours), automatic issuance, automatic rotation, and a CRL/OCSP story. Short-lived certs eliminate the revocation problem — an attacker who grabs a cert has it for hours, not a year.

Scope your CA. Don’t issue all your service certs from one CA. Compromise of that CA invalidates trust in everything. At minimum, use one CA per environment (dev/staging/prod) and ideally a per-team or per-service CA hierarchy. Vault’s PKI backend handles this cleanly.

Extract identity from the certificate, not from a header. Once mTLS is in place, the client’s CN or SAN is a verified identity — cryptographically bound to a private key. Route authorization decisions through that. Don’t also add an X-Client-ID header and trust it; headers are trivially forgeable, even inside a cluster. The cert is the identity.

Pin TLS 1.3 when you own both ends. If you control both the client and server, enforce TLS 1.3 with MinVersion: tls.VersionTLS13 in Go or lean on rustls’s defaults (which already reject everything below 1.2 and prefer 1.3). TLS 1.3 removes the RSA key exchange, mandates forward secrecy, and cuts the handshake to one round-trip. There’s no reason to accept 1.2 if you don’t need to.

Log handshake failures with peer info. A rejected connection isn’t just an error — it’s a signal. An unknown service trying to connect might be misconfiguration, might be a compromised workload probing what it can reach. Log the remote IP, the error, and the presented certificate (if any) at WARN level. Don’t log at DEBUG — by the time you need it, debug logging is off.


The whole pattern is around 150 lines of actual code between the two languages. Zero dependencies in Go; a handful of well-audited crates in Rust. The certificate generation is a one-time five-minute setup. There’s no reason the threshold for "adding mTLS is too much work" is as high as it usually is in practice. Roll it on internal APIs, internal gRPC, anything that talks service-to-service. The default for new services should be mTLS on, not mTLS if we have time.

Leave a comment

👁 Views: 2,289 · Unique visitors: 1,646