How to Set Up Mutual TLS Between Two Go Services with Your Own Private CA
Ordinary TLS answers one question: is this server who it claims to be. It does nothing to answer the other question that matters just as much inside a backend: is this client who it claims to be. If your order service and your billing service talk over plain HTTPS on an internal network, anything that can reach that port and complete a TLS handshake can call the API. A network policy might stop it, a service mesh sidecar might stop it, but at the application layer there is no check at all.
Mutual TLS (mTLS) closes that gap by making both sides present a certificate. The server checks the client's certificate against a trusted CA before it processes a single byte of the request, and the client checks the server's certificate the same way it always did. For a small number of internal services, you do not need a commercial CA, ACME, or a service mesh to get this. You need about eighty lines of Go and a private key you keep somewhere sensible.
What you are actually building
Three things: a CA certificate and key, a server certificate signed by that CA, and a client certificate signed by the same CA. The server trusts anything the CA signed for client authentication; the client trusts anything the CA signed for server authentication. Nobody outside your organisation is involved, and there is no revocation infrastructure to run, because internal certificates are cheap to reissue and short-lived certificates make revocation largely unnecessary.
Go's crypto/x509 package can do the whole job without shelling out to openssl, which is worth doing here because it means the same tool that consumes these certificates can also generate them, and you get to see exactly what fields matter.
Building the CA and issuing certificates
This is a throwaway command-line tool, not a long-running service, so log.Fatal on setup errors is the right level of ceremony: there is no caller to recover to.
package main
import (
"crypto/ecdsa"
"crypto/elliptic"
"crypto/rand"
"crypto/x509"
"crypto/x509/pkix"
"encoding/pem"
"log"
"math/big"
"os"
"time"
)
func serial() *big.Int {
n, err := rand.Int(rand.Reader, new(big.Int).Lsh(big.NewInt(1), 128))
if err != nil {
log.Fatal(err)
}
return n
}
func makeCA() (*ecdsa.PrivateKey, *x509.Certificate, []byte) {
key, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
if err != nil {
log.Fatal(err)
}
tmpl := &x509.Certificate{
SerialNumber: serial(),
Subject: pkix.Name{CommonName: "internal-ca"},
NotBefore: time.Now().Add(-time.Hour),
NotAfter: time.Now().AddDate(10, 0, 0),
KeyUsage: x509.KeyUsageCertSign | x509.KeyUsageCRLSign,
BasicConstraintsValid: true,
IsCA: true,
}
der, err := x509.CreateCertificate(rand.Reader, tmpl, tmpl, &key.PublicKey, key)
if err != nil {
log.Fatal(err)
}
cert, err := x509.ParseCertificate(der)
if err != nil {
log.Fatal(err)
}
return key, cert, der
}
func makeLeaf(caKey *ecdsa.PrivateKey, caCert *x509.Certificate, cn string, dnsNames []string, eku x509.ExtKeyUsage) (*ecdsa.PrivateKey, []byte) {
key, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
if err != nil {
log.Fatal(err)
}
tmpl := &x509.Certificate{
SerialNumber: serial(),
Subject: pkix.Name{CommonName: cn},
NotBefore: time.Now().Add(-time.Hour),
NotAfter: time.Now().AddDate(0, 0, 90),
KeyUsage: x509.KeyUsageDigitalSignature,
ExtKeyUsage: []x509.ExtKeyUsage{eku},
DNSNames: dnsNames,
}
der, err := x509.CreateCertificate(rand.Reader, tmpl, caCert, &key.PublicKey, caKey)
if err != nil {
log.Fatal(err)
}
return key, der
}
func writeKey(path string, key *ecdsa.PrivateKey) {
der, err := x509.MarshalECPrivateKey(key)
if err != nil {
log.Fatal(err)
}
f, err := os.OpenFile(path, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, 0o600)
if err != nil {
log.Fatal(err)
}
defer f.Close()
if err := pem.Encode(f, &pem.Block{Type: "EC PRIVATE KEY", Bytes: der}); err != nil {
log.Fatal(err)
}
}
func writeCert(path string, der []byte) {
f, err := os.OpenFile(path, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, 0o644)
if err != nil {
log.Fatal(err)
}
defer f.Close()
if err := pem.Encode(f, &pem.Block{Type: "CERTIFICATE", Bytes: der}); err != nil {
log.Fatal(err)
}
}
func main() {
caKey, caCert, caDER := makeCA()
writeKey("ca-key.pem", caKey)
writeCert("ca-cert.pem", caDER)
serverKey, serverDER := makeLeaf(caKey, caCert, "orders-service",
[]string{"orders.internal"}, x509.ExtKeyUsageServerAuth)
writeKey("server-key.pem", serverKey)
writeCert("server-cert.pem", serverDER)
clientKey, clientDER := makeLeaf(caKey, caCert, "billing-client",
nil, x509.ExtKeyUsageClientAuth)
writeKey("client-key.pem", clientKey)
writeCert("client-cert.pem", clientDER)
}
A few details are worth pausing on. The CA certificate is self-signed: its own key signs its own template, which is exactly what makes it a root. The BasicConstraintsValid and IsCA fields are what mark it as a signing certificate rather than a leaf; leave them off a leaf certificate and some TLS stacks will refuse to build a chain through it. The ExtKeyUsage field matters more than it looks: a certificate issued with only ExtKeyUsageServerAuth will be rejected if you try to use it as a client certificate, and vice versa, because Go's TLS stack checks it during verification. Ninety days for leaf certificates is a reasonable starting point for infrastructure you reissue with a cron job or a deploy pipeline; there is no need to build revocation checking for certificates that expire before anyone would notice they were compromised.
Configuring the server
The server side needs two things beyond a normal HTTPS setup: a certificate pool containing the CA (used to verify incoming client certificates, not to verify itself), and tls.RequireAndVerifyClientCert to make presenting a valid certificate mandatory rather than optional.
package main
import (
"crypto/tls"
"crypto/x509"
"fmt"
"log"
"net/http"
"os"
)
func main() {
caPEM, err := os.ReadFile("ca-cert.pem")
if err != nil {
log.Fatal(err)
}
clientCAs := x509.NewCertPool()
if !clientCAs.AppendCertsFromPEM(caPEM) {
log.Fatal("failed to parse CA certificate")
}
cert, err := tls.LoadX509KeyPair("server-cert.pem", "server-key.pem")
if err != nil {
log.Fatal(err)
}
mux := http.NewServeMux()
mux.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
peer := r.TLS.PeerCertificates[0]
fmt.Fprintf(w, "hello, %s\n", peer.Subject.CommonName)
})
srv := &http.Server{
Addr: ":8443",
Handler: mux,
TLSConfig: &tls.Config{
Certificates: []tls.Certificate{cert},
ClientCAs: clientCAs,
ClientAuth: tls.RequireAndVerifyClientCert,
MinVersion: tls.VersionTLS13,
},
}
log.Fatal(srv.ListenAndServeTLS("", ""))
}
Note that ListenAndServeTLS is called with empty strings for the certificate and key paths, because they are already loaded into TLSConfig.Certificates. Passing paths there as well is a common copy-paste mistake that silently overrides the config you just built.
Configuring the client
The client is the mirror image: RootCAs to verify the server, and its own certificate to present when asked.
package main
import (
"crypto/tls"
"crypto/x509"
"fmt"
"io"
"log"
"net/http"
"os"
)
func main() {
caPEM, err := os.ReadFile("ca-cert.pem")
if err != nil {
log.Fatal(err)
}
rootCAs := x509.NewCertPool()
if !rootCAs.AppendCertsFromPEM(caPEM) {
log.Fatal("failed to parse CA certificate")
}
cert, err := tls.LoadX509KeyPair("client-cert.pem", "client-key.pem")
if err != nil {
log.Fatal(err)
}
client := &http.Client{
Transport: &http.Transport{
TLSClientConfig: &tls.Config{
Certificates: []tls.Certificate{cert},
RootCAs: rootCAs,
MinVersion: tls.VersionTLS13,
},
},
}
resp, err := client.Get("https://orders.internal:8443/")
if err != nil {
log.Fatal(err)
}
defer resp.Body.Close()
body, err := io.ReadAll(resp.Body)
if err != nil {
log.Fatal(err)
}
fmt.Print(string(body))
}
The hostname orders.internal has to resolve to wherever the server is actually listening, whether through real internal DNS or a line in /etc/hosts for local testing, and it has to match a DNS SAN on the server certificate. tls.Config.ServerName lets you override this if you need to dial by IP while still verifying against a hostname.
Proving it actually enforces anything
Run the server, then try reaching it with curl and no client certificate:
curl --cacert ca-cert.pem https://orders.internal:8443/
That should fail with a TLS alert, something like "certificate required" or a bare connection reset, depending on your curl version. Add the client credentials and it should succeed:
curl --cacert ca-cert.pem \
--cert client-cert.pem --key client-key.pem \
https://orders.internal:8443/
If the second command works and the first does not, the handshake-level enforcement is doing its job.
The gotcha almost every mTLS write-up glosses over
Here is the bit that actually matters once you have more than one client. tls.RequireAndVerifyClientCert verifies that the presented certificate chains to your CA and hasn't expired. It says nothing about which client it is. If you issue a certificate to the billing service and a separate one to a reporting job, both will pass verification on every endpoint the orders service exposes, because both were signed by the same trusted CA. Mutual TLS on its own authenticates "someone we issued a certificate to", not "the specific service we meant to allow here".
The handler above reads r.TLS.PeerCertificates[0].Subject.CommonName and does nothing with it. In anything beyond a toy, that value needs to feed an authorisation check: an allowlist of CommonNames or, better, URI SANs formatted as stable service identifiers, checked against the specific route being called. This is the same principle SPIFFE formalises with its spiffe://trust-domain/service URI SAN convention, and if you find yourself managing more than a handful of services it is worth adopting rather than reinventing informally with CommonNames. For two or three internal services, an explicit map from CommonName to allowed operations, checked at the top of each handler, is perfectly adequate and considerably less to operate than a service mesh.
One more thing worth deciding deliberately: where the CA private key lives. For a handful of long-lived internal services, keeping ca-key.pem on an operator's encrypted workstation or in a secrets manager and running the issuance tool by hand every few months is simpler and safer than automating signing on a server that is also reachable from the network the certificates protect.