mTLS client and server in Go
Ordinary TLS authenticates only the server: the client checks the server’s certificate against a trusted CA, but the server has no cryptographic proof of who the client is. Mutual TLS (mTLS) closes that gap by making both sides present a certificate during the handshake. The server verifies the client’s certificate just as the client verifies the server’s, so identity is established at the transport layer before a single byte of application data flows.
This is the workhorse pattern for service-to-service authentication inside a cluster, for API clients that must be strongly identified, and for any link where a shared bearer token is too weak.
Go’s standard library covers the whole thing: crypto/tls, crypto/x509, and net/http, no third-party dependencies.
How the Handshake Changes
In a normal TLS handshake the server sends its certificate and the client validates it. With mTLS the server additionally sends a CertificateRequest, and the client answers with its own certificate plus a signature proving it holds the matching private key.
sequenceDiagram
participant C as Client
participant S as Server
C->>S: ClientHello
S->>C: ServerHello, Certificate
S->>C: CertificateRequest
C->>S: Certificate (client)
C->>S: CertificateVerify (signature)
Note over C,S: both sides verified against the CA
C->>S: Finished
S->>C: Finished
Note over C,S: encrypted application data
The extra CertificateRequest and CertificateVerify messages are the entire difference. Everything else is standard TLS.
The Trust Model
Every certificate in this scheme is signed by a Certificate Authority (CA). The server trusts a client because the client’s certificate chains up to a CA the server holds; the client trusts the server the same way.
In the examples below a single CA signs both the server and client certificates. In production you often split these into a server CA and a client CA so you can rotate and revoke them independently, but the mechanics are identical: each side is configured with the CA pool it verifies the other against.
Generating a CA and Certificates
Before wiring up TLS you need certificates. Rather than reach for openssl, generating them in Go keeps the whole example self-contained and shows exactly which fields matter for mTLS. The two that matter most are ExtKeyUsage (a client cert needs ExtKeyUsageClientAuth, a server cert needs ExtKeyUsageServerAuth) and, for the server, the DNSNames/IPAddresses the client will verify.
package main
import (
"crypto/ecdsa"
"crypto/elliptic"
"crypto/rand"
"crypto/x509"
"crypto/x509/pkix"
"encoding/pem"
"math/big"
"net"
"os"
"time"
)
// newSerial returns a random 128-bit serial number for a certificate.
func newSerial() *big.Int {
limit := new(big.Int).Lsh(big.NewInt(1), 128)
serial, err := rand.Int(rand.Reader, limit)
if err != nil {
panic(err)
}
return serial
}
// writePEM encodes a single DER block to a PEM file.
func writePEM(path, blockType string, der []byte) {
f, err := os.Create(path)
if err != nil {
panic(err)
}
defer f.Close()
if err := pem.Encode(f, &pem.Block{Type: blockType, Bytes: der}); err != nil {
panic(err)
}
}
// writeKey marshals an EC private key to a PKCS#8 PEM file.
func writeKey(path string, key *ecdsa.PrivateKey) {
der, err := x509.MarshalPKCS8PrivateKey(key)
if err != nil {
panic(err)
}
writePEM(path, "PRIVATE KEY", der)
}
func main() {
// The CA is the single trust anchor for both server and clients.
caKey, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
if err != nil {
panic(err)
}
caTmpl := &x509.Certificate{
SerialNumber: newSerial(),
Subject: pkix.Name{CommonName: "demo-ca"},
NotBefore: time.Now().Add(-time.Hour),
NotAfter: time.Now().Add(365 * 24 * time.Hour),
IsCA: true,
KeyUsage: x509.KeyUsageCertSign | x509.KeyUsageCRLSign,
BasicConstraintsValid: true,
}
caDER, err := x509.CreateCertificate(rand.Reader, caTmpl, caTmpl, &caKey.PublicKey, caKey)
if err != nil {
panic(err)
}
caCert, err := x509.ParseCertificate(caDER)
if err != nil {
panic(err)
}
writePEM("ca.pem", "CERTIFICATE", caDER)
// The server certificate carries the ServerAuth usage and the names
// clients will verify against.
srvKey, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
if err != nil {
panic(err)
}
srvTmpl := &x509.Certificate{
SerialNumber: newSerial(),
Subject: pkix.Name{CommonName: "localhost"},
NotBefore: time.Now().Add(-time.Hour),
NotAfter: time.Now().Add(365 * 24 * time.Hour),
KeyUsage: x509.KeyUsageDigitalSignature,
ExtKeyUsage: []x509.ExtKeyUsage{x509.ExtKeyUsageServerAuth},
DNSNames: []string{"localhost"},
IPAddresses: []net.IP{net.ParseIP("127.0.0.1")},
}
srvDER, err := x509.CreateCertificate(rand.Reader, srvTmpl, caCert, &srvKey.PublicKey, caKey)
if err != nil {
panic(err)
}
writePEM("server.pem", "CERTIFICATE", srvDER)
writeKey("server-key.pem", srvKey)
// The client certificate carries the ClientAuth usage. Its CommonName
// is the identity the server will read on every request.
cliKey, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
if err != nil {
panic(err)
}
cliTmpl := &x509.Certificate{
SerialNumber: newSerial(),
Subject: pkix.Name{CommonName: "client-1"},
NotBefore: time.Now().Add(-time.Hour),
NotAfter: time.Now().Add(365 * 24 * time.Hour),
KeyUsage: x509.KeyUsageDigitalSignature,
ExtKeyUsage: []x509.ExtKeyUsage{x509.ExtKeyUsageClientAuth},
}
cliDER, err := x509.CreateCertificate(rand.Reader, cliTmpl, caCert, &cliKey.PublicKey, caKey)
if err != nil {
panic(err)
}
writePEM("client.pem", "CERTIFICATE", cliDER)
writeKey("client-key.pem", cliKey)
println("wrote ca.pem, server.pem, server-key.pem, client.pem, client-key.pem")
}
Running this drops five files in the working directory: the CA certificate, and a certificate/key pair each for the server and the client. Those are the inputs for both programs below.
The Server
The only mTLS-specific lines in the server are ClientAuth and ClientCAs. Setting ClientAuth to tls.RequireAndVerifyClientCert tells Go to demand a client certificate and reject the connection during the handshake if the client does not present one that chains to ClientCAs.
package main
import (
"crypto/tls"
"crypto/x509"
"fmt"
"log"
"net/http"
"os"
)
// loadCACertPool reads a PEM bundle and returns a pool to verify peers against.
func loadCACertPool(path string) (*x509.CertPool, error) {
pem, err := os.ReadFile(path)
if err != nil {
return nil, err
}
pool := x509.NewCertPool()
if !pool.AppendCertsFromPEM(pem) {
return nil, fmt.Errorf("no certificates found in %s", path)
}
return pool, nil
}
func main() {
clientCAs, err := loadCACertPool("ca.pem")
if err != nil {
log.Fatal(err)
}
tlsConfig := &tls.Config{
// Require and verify a client certificate on every connection.
ClientAuth: tls.RequireAndVerifyClientCert,
ClientCAs: clientCAs,
MinVersion: tls.VersionTLS12,
}
mux := http.NewServeMux()
mux.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
// After the handshake the verified chains are on the request.
peer := r.TLS.PeerCertificates[0]
fmt.Fprintf(w, "hello %s\n", peer.Subject.CommonName)
})
server := &http.Server{
Addr: "127.0.0.1:8443",
Handler: mux,
TLSConfig: tlsConfig,
}
log.Println("listening on https://127.0.0.1:8443")
// The cert/key here are the server's own identity.
if err := server.ListenAndServeTLS("server.pem", "server-key.pem"); err != nil {
log.Fatal(err)
}
}
By the time a handler runs, the client is already authenticated: the handshake would have failed otherwise. The verified certificate is available on r.TLS.PeerCertificates[0], so reading Subject.CommonName gives you the caller’s identity without any application-level token.
The ClientAuth Levels
ClientAuth is not just on/off. crypto/tls defines five levels, and picking the wrong one is a common way to think mTLS is enforced when it is not:
| Value | Behavior |
|---|---|
NoClientCert | Never asks for a client certificate (plain TLS). |
RequestClientCert | Asks, but accepts a connection with none. |
RequireAnyClientCert | Requires a cert but does not verify the chain. |
VerifyClientCertIfGiven | Verifies only if the client offers one. |
RequireAndVerifyClientCert | Requires a valid, CA-chained cert. |
For real mTLS you want RequireAndVerifyClientCert. The RequireAnyClientCert level is a trap: it forces a certificate but skips verification, so any self-signed cert gets in.
The Client
The client mirrors the server. It loads its own certificate into Certificates so it can answer the server’s CertificateRequest, and it sets RootCAs so it verifies the server’s certificate against the same CA.
package main
import (
"crypto/tls"
"crypto/x509"
"fmt"
"io"
"log"
"net/http"
"os"
)
func loadCACertPool(path string) (*x509.CertPool, error) {
pem, err := os.ReadFile(path)
if err != nil {
return nil, err
}
pool := x509.NewCertPool()
if !pool.AppendCertsFromPEM(pem) {
return nil, fmt.Errorf("no certificates found in %s", path)
}
return pool, nil
}
func main() {
// The client presents its own certificate to the server.
cert, err := tls.LoadX509KeyPair("client.pem", "client-key.pem")
if err != nil {
log.Fatal(err)
}
// The client verifies the server against the same CA.
rootCAs, err := loadCACertPool("ca.pem")
if err != nil {
log.Fatal(err)
}
tlsConfig := &tls.Config{
Certificates: []tls.Certificate{cert},
RootCAs: rootCAs,
MinVersion: tls.VersionTLS12,
}
client := &http.Client{
Transport: &http.Transport{TLSClientConfig: tlsConfig},
}
resp, err := client.Get("https://localhost:8443/")
if err != nil {
log.Fatal(err)
}
defer resp.Body.Close()
body, err := io.ReadAll(resp.Body)
if err != nil {
log.Fatal(err)
}
fmt.Printf("status: %s\nbody: %s", resp.Status, body)
}
Note the request targets https://localhost:8443/, not 127.0.0.1. TLS verifies the hostname against the certificate’s SANs, and the server certificate lists localhost as a DNS name. Using a name or IP not present in the certificate fails verification, which is exactly the protection you want.
Running It End to End
Generate the certificates, start the server, then run the client:
$ go run ./certs # writes the five PEM files
$ go run ./server & # listening on https://127.0.0.1:8443
$ go run ./client
status: 200 OK
body: hello client-1
The server greets the client by the CommonName from its certificate. To see the enforcement, try curl without a client certificate against the same server:
$ curl --cacert ca.pem https://localhost:8443/
curl: (56) OpenSSL SSL_read: ... tls: client didn't provide a certificate
$ curl --cacert ca.pem --cert client.pem --key client-key.pem https://localhost:8443/
hello client-1
The unauthenticated request is dropped during the handshake, before it ever reaches the handler. Supplying the client certificate and key makes it succeed.
Beyond Chain Verification: Authorizing the Caller
Chaining to the CA proves the client is someone the CA vouched for, not that it is allowed to call this endpoint. Those are different questions. A CA that signs certificates for a hundred services vouches for all of them; a given endpoint may want to admit only three.
The cleanest place to enforce an allowlist is VerifyPeerCertificate, a hook that runs after the standard chain build succeeds. It receives the verified chains, so you can inspect the leaf certificate and reject identities you do not recognize.
// allowedClient enforces an allowlist of client CommonNames after the
// standard chain verification has already succeeded.
func allowedClient(allowed map[string]bool) func([][]byte, [][]*x509.Certificate) error {
return func(_ [][]byte, verifiedChains [][]*x509.Certificate) error {
if len(verifiedChains) == 0 || len(verifiedChains[0]) == 0 {
return errors.New("no verified client chain")
}
cn := verifiedChains[0][0].Subject.CommonName
if !allowed[cn] {
return errors.New("client not authorized: " + cn)
}
return nil
}
}
Wire it into the server’s tls.Config alongside the existing fields:
tlsConfig := &tls.Config{
ClientAuth: tls.RequireAndVerifyClientCert,
ClientCAs: clientCAs,
MinVersion: tls.VersionTLS12,
VerifyPeerCertificate: allowedClient(map[string]bool{
"client-1": true,
}),
}
Now a certificate that is valid and CA-signed but whose CommonName is not on the list is rejected at handshake time. This keeps authorization at the transport layer for coarse-grained access, while finer decisions can still read r.TLS.PeerCertificates inside a handler.
For anything beyond a small static set, matching on CommonName is fragile. Prefer a SPIFFE-style URI SAN (for example spiffe://cluster/ns/payments/sa/api) and match on that, since URIs are structured, namespaced, and less prone to collision than a free-form name.
Reloading Certificates Without a Restart
Certificates expire, and a long-lived server should not have to restart to pick up a renewed pair. Passing file paths to ListenAndServeTLS loads them once at startup. To rotate on the fly, load the pair yourself and hand tls.Config a GetCertificate callback that reads the current value on every handshake:
// certReloader holds the active certificate behind an atomic pointer so
// GetCertificate can swap it without locking the handshake path.
type certReloader struct {
cert atomic.Pointer[tls.Certificate]
}
func (cr *certReloader) load(certPath, keyPath string) error {
cert, err := tls.LoadX509KeyPair(certPath, keyPath)
if err != nil {
return err
}
cr.cert.Store(&cert)
return nil
}
func (cr *certReloader) GetCertificate(*tls.ClientHelloInfo) (*tls.Certificate, error) {
return cr.cert.Load(), nil
}
Call load once at startup, then again whenever the files change (on a timer, or on SIGHUP). Because GetCertificate reads the atomic pointer per handshake, new connections pick up the fresh certificate immediately while in-flight connections keep the old one. The same pattern applies on the client via GetClientCertificate.
Production Notes
A few things separate a demo from a deployment:
- Split CAs. Use one CA for server certificates and another for client certificates. It lets you revoke or rotate one population without touching the other, and it prevents a server certificate from ever being accepted as a client identity.
- Short lifetimes. The examples use one-year certificates for convenience. Short-lived certificates (hours or days) issued by an automated system shrink the window a leaked key is useful and reduce the need for revocation infrastructure.
- Revocation. Chain verification does not consult CRLs or OCSP by default. If you cannot rely on short lifetimes, plumb revocation checks into
VerifyPeerCertificate. - TLS 1.3. Set
MinVersion: tls.VersionTLS12at minimum; prefertls.VersionTLS13where every peer supports it. In TLS 1.3 the client’s certificate is sent encrypted, which is a privacy win over 1.2.
Conclusion
mTLS in Go is a small, standard-library affair. The server sets ClientAuth: tls.RequireAndVerifyClientCert with a ClientCAs pool; the client supplies its own Certificates and a RootCAs pool. From there the handshake authenticates both ends, and the verified identity is waiting on r.TLS.PeerCertificates before your handler runs.
The pieces that turn it into something production-grade — allowlisting callers in VerifyPeerCertificate, hot-reloading certificates through GetCertificate, splitting and shortening the certificate lifecycle — all build on that same handful of tls.Config fields. If you want to push the cryptography further, the same server pattern extends to post-quantum key exchange, as covered in Post-Quantum HTTPS Server in Go.