Originally published on Code Beneath.
A TLS handshake happens every time your browser connects to an HTTPS site. A mutual TLS or mTLS handshake is the same process, except the server also verifies the client's certificate. Most developers understand the concept but struggle when building systems that require mTLS authentication, because they've never seen what actually crosses the wire. This article walks through a real mtls handshake example with packet-level detail using openssl s_client, then provides a complete, runnable Go server and client you can execute immediately.
Why mTLS Matters
Standard TLS (one-way TLS) proves the server's identity to the client. The client trusts the server because a Certificate Authority signed the server's certificate. But what if you're running microservices on your own network and want to ensure only your authorized services can connect to each other? That's where mTLS comes in. Each service has its own certificate, signed by your private CA. When service A connects to service B, service B demands to see A's certificate and validates it. Service A, in turn, validates service B's certificate. Both sides prove their identity. This is standard in service mesh implementations (Istio, Linkerd) and in Kubernetes clusters.
The TLS Handshake: ClientHello to Finished
Before we look at mTLS specifically, let's trace a standard TLS handshake. The client sends a ClientHello with supported cipher suites, TLS versions, and extensions. The server responds with ServerHello, picks a cipher suite, and sends its certificate chain. The client validates the chain against a trusted CA. Both sides compute a session key. The server says "done," the client says "done," and encrypted communication begins.
In mTLS, after the server sends its certificate, the server sends a CertificateRequest message telling the client "I also want your certificate." The client sends its certificate. The server validates it. Then both parties send a Finished message, and the handshake completes. The entire conversation is still in plaintext until the Finished message, which is encrypted with the derived session key to prove both sides computed the same key and no one tampered with the handshake.
Seeing the Handshake Byte-by-Byte with openssl s_client
Let's first create a self-signed certificate and a minimal HTTPS server to examine. Then we'll use openssl to spy on what happens.
# Generate a self-signed server certificate valid for 365 days
openssl req -x509 -newkey rsa:2048 -nodes \
-out server.crt -keyout server.key -days 365 \
-subj "/CN=localhost"
Now create a minimal Python HTTPS server to serve that certificate:
import ssl
import http.server
import socketserver
PORT = 8443
class MyHTTPSHandler(http.server.SimpleHTTPRequestHandler):
def do_GET(self):
self.send_response(200)
self.send_header("Content-type", "text/plain")
self.end_headers()
self.wfile.write(b"Hello from HTTPS\n")
context = ssl.SSLContext(ssl.PROTOCOL_TLS_SERVER)
context.load_cert_chain("server.crt", "server.key")
with socketserver.TCPServer(("127.0.0.1", PORT), MyHTTPSHandler) as httpd:
print(f"Listening on https://127.0.0.1:{PORT}")
httpd.serve_forever()
Run the server in one terminal:
python3 https_server.py
In another terminal, use openssl s_client with verbose output to watch the handshake:
openssl s_client -connect 127.0.0.1:8443 -servername localhost -showcerts < /dev/null
You'll see output similar to this (simplified):
CONNECTED(00000003)
depth=0 CN = localhost
verify error:num=18:self signed certificate
verify return:1
---
Certificate chain
0 s:CN = localhost
i:CN = localhost
-----BEGIN CERTIFICATE-----
MIICpDCCAYwCCQC+...
-----END CERTIFICATE-----
subject=CN = localhost
issuer=CN = localhost
---
No client certificate CA names sent
---
SSL-Session:
Protocol : TLSv1.3
Cipher : TLS_AES_256_GCM_SHA384
Session-ID: ...
Notice "No client certificate CA names sent" because the server is not requiring client authentication. That's standard TLS. Now let's build mTLS.
Building mTLS: Generate Client and Server Certificates
For mTLS, we need a CA, a server certificate signed by that CA, and a client certificate signed by the same CA.
# Step 1: Create a self-signed CA
openssl genrsa -out ca.key 2048
openssl req -new -x509 -days 365 -key ca.key -out ca.crt \
-subj "/CN=MyCA"
# Step 2: Create a server certificate signed by the CA
openssl genrsa -out server.key 2048
openssl req -new -key server.key -out server.csr \
-subj "/CN=localhost"
openssl x509 -req -days 365 -in server.csr \
-CA ca.crt -CAkey ca.key -CAcreateserial \
-out server.crt
# Step 3: Create a client certificate signed by the CA
openssl genrsa -out client.key 2048
openssl req -new -key client.key -out client.csr \
-subj "/CN=clientuser"
openssl x509 -req -days 365 -in client.csr \
-CA ca.crt -CAkey ca.key -CAcreateserial \
-out client.crt
Now we have six files: ca.key, ca.crt (the CA), server.key, server.crt (server credentials), and client.key, client.crt (client credentials).
mTLS Server and Client in Go
Here's a complete, runnable Go HTTPS server that requires client certificates:
package main
import (
"crypto/tls"
"crypto/x509"
"fmt"
"io/ioutil"
"log"
"net"
"net/http"
)
func handler(w http.ResponseWriter, r *http.Request) {
clientCert := r.TLS.PeerCertificates[0]
fmt.Fprintf(w, "Hello, %s!\n", clientCert.Subject.CommonName)
fmt.Fprintf(w, "You connected with certificate serial: %s\n", clientCert.SerialNumber)
}
func main() {
// Load CA certificate to verify client certificates
caCertPEM, err := ioutil.ReadFile("ca.crt")
if err != nil {
log.Fatal(err)
}
caCertPool := x509.NewCertPool()
if !caCertPool.AppendCertsFromPEM(caCertPEM) {
log.Fatal("Failed to parse CA certificate")
}
// Load server certificate and key
serverCert, err := tls.LoadX509KeyPair("server.crt", "server.key")
if err != nil {
log.Fatal(err)
}
// Create TLS configuration for the server
tlsConfig := &tls.Config{
Certificates: []tls.Certificate{serverCert},
ClientAuth: tls.RequireAndVerifyClientCert,
ClientCAs: caCertPool,
}
// Create a listener with TLS
listener, err := tls.Listen("tcp", "127.0.0.1:8443", tlsConfig)
if err != nil {
log.Fatal(err)
}
defer listener.Close()
// Wrap listener to extract peer info for logging
http.HandleFunc("/", handler)
server := &http.Server{
TLSConfig: tlsConfig,
}
fmt.Println("mTLS server listening on https://127.0.0.1:8443")
fmt.Println("Press Ctrl+C to stop")
if err := server.Serve(listener); err != nil && err != http.ErrServerClosed {
log.Fatal(err)
}
}
Save this as mtls_server.go. Now the client:
package main
import (
"crypto/tls"
"crypto/x509"
"fmt"
"io/ioutil"
"log"
"net/http"
)
func main() {
// Load CA certificate to verify server
caCertPEM, err := ioutil.ReadFile("ca.crt")
if err != nil {
log.Fatal(err)
}
caCertPool := x509.NewCertPool()
if !caCertPool.AppendCertsFromPEM(caCertPEM) {
log.Fatal("Failed to parse CA certificate")
}
// Load client certificate and key
clientCert, err := tls.LoadX509KeyPair("client.crt", "client.key")
if err != nil {
log.Fatal(err)
}
// Create TLS configuration for the client
tlsConfig := &tls.Config{
Certificates: []tls.Certificate{clientCert},
RootCAs: caCertPool,
}
// Create HTTP client with custom TLS config
client := &http.Client{
Transport: &http.Transport{
TLSClientConfig: tlsConfig,
},
}
// Make request to server
resp, err := client.Get("https://127.0.0.1:8443/")
if err != nil {
log.Fatal(err)
}
defer resp.Body.Close()
body, err := ioutil.ReadAll(resp.Body)
if err != nil {
log.Fatal(err)
}
fmt.Println(string(body))
}
Save this as mtls_client.go. Now test it:
# Terminal 1: Run the server
go run mtls_server.go
# Terminal 2: Run the client
go run mtls_client.go
Output from the client:
Hello, clientuser!
You connected with certificate serial: ...
The handshake succeeded. Now let's see what openssl reports about client certificates being requested:
openssl s_client -connect 127.0.0.1:8443 -cert client.crt -key client.key \
-CAfile ca.crt < /dev/null
Look for the line "Verify return code: 0 (ok)" and notice now it says "Client certificate requested" in the output. The handshake includes the CertificateRequest message.
What Happens If the Client Has No Certificate
Try connecting without a client certificate:
openssl s_client -connect 127.0.0.1:8443 < /dev/null 2>&1 | head -20
You'll see an error during the handshake. The server expects a certificate, and the connection drops. This is the security boundary: only clients with a valid certificate signed by your CA can connect.
Comparison: One-Way TLS vs. mTLS
| Aspect | One-Way TLS | mTLS (Mutual TLS) |
|---|---|---|
| Server proves identity | Yes | Yes |
| Client proves identity | No | Yes |
| Server validates client cert | No | Yes |
| Use case | Public websites (HTTPS) | Microservices, internal APIs, service mesh |
| ClientAuth setting | tls.NoClientCert or tls.RequestClientCert | tls.RequireAndVerifyClientCert |
| Handshake message count | ~8 messages | ~10 messages (includes CertificateRequest, client cert, client cert verify) |
Common Mistakes
Forgetting to set ClientCAs. If you set ClientAuth to RequireAndVerifyClientCert but don't load the CA certificates into ClientCAs, every client certificate validation will fail. The server has nothing to verify against.
Mixing up RootCAs and ClientCAs. In a client, RootCAs is for verifying the server's certificate. In a server, ClientCAs is for verifying the client's certificate. They often contain the same CA cert, but the role is different. The Go docs can be confusing here.
Using tls.InsecureSkipVerify in production. This disables all certificate validation and opens you to man-in-the-middle attacks. Never do this outside of local testing.
Forgetting certificate rotation. Certificates expire. In a service mesh or large deployment, you need a process to rotate certificates before they expire. This is where tools like cert-manager (in Kubernetes) come in, but that's a separate topic.
Not understanding the CN vs. SANs issue. In modern TLS, the CommonName (CN) is deprecated; use Subject Alternative Names (SANs) instead. If you need your certificate to work for both localhost and 127.0.0.1, add both as SANs when generating the certificate. For this article's simple example, CN is fine.
Integration with Token-Based Security
mTLS and token-based authentication are complementary. mTLS secures the transport layer and proves the client's identity at the network level. On top of that, you can layer token-based mechanisms (JWT, OAuth) for application-level authorization. For a detailed treatment of how tokens work in web applications, see token-based security in web applications.
Common Pitfalls with Certificate Management
In a real system, managing certificates securely is critical. Never commit private keys to version control. Treat them like passwords. See managing password safety on Linux for best practices on securing sensitive files. The same principles apply to certificate keys.
What Happens Inside the Handshake
Here's the step-by-step message flow for mTLS:
- ClientHello: Client sends supported cipher suites, TLS versions, and random nonce.
- ServerHello: Server picks a cipher suite and sends its random nonce.
- Certificate: Server sends its certificate chain.
- CertificateRequest: Server tells the client it must also send a certificate. This message is absent in standard TLS.
- ServerKeyExchange or direct key agreement: Server sends key agreement parameters (or skips this in TLS 1.3).
- ServerHelloDone: Server says "your turn."
- Certificate: Client sends its certificate.
- ClientKeyExchange: Client sends key agreement parameters.
- CertificateVerify: Client signs a hash of the handshake transcript with its private key, proving it owns the certificate. This is absent in standard TLS.
- Finished: Client sends an encrypted message proving the session key is correct.
- Finished: Server responds with its own encrypted Finished message.
The CertificateRequest, client Certificate, and CertificateVerify messages are the three additions that make mTLS mutual instead of one-way.
Testing mTLS with curl
Once your server is running, you can also test with curl:
curl --cert client.crt --key client.key \
--cacert ca.crt \
https://127.0.0.1:8443/
This is useful for manual testing of mTLS endpoints. curl requires the --cert flag (client certificate), --key flag (client private key), and --cacert flag (CA certificate to verify the server). Without all three, the handshake will fail.
Performance Considerations
mTLS adds negligible overhead to each request after the initial handshake. The handshake itself takes a few milliseconds. In a service mesh where services are constantly opening new connections (or reusing them via connection pooling), the handshake cost is amortized. The real cost is CPU for certificate validation and TLS encryption/decryption, but modern CPUs with AES-NI instructions handle this efficiently. For internal microservices, mTLS is the right choice; the security benefit far outweighs the performance cost.
FAQ
Why is mTLS needed if I'm already using TLS for HTTPS?
Standard HTTPS proves the server's identity to the client. mTLS also proves the client's identity to the server. In a public website, you don't need to verify the client because millions of strangers visit. In a private network of microservices, you want each service to authenticate itself. mTLS is a control boundary: only services with valid certificates can connect.
Can I use self-signed certificates with mTLS?
Yes, as shown in this article. For production, you typically run your own CA (private CA) or use a tool like cert-manager to manage certificates. Self-signed certificates are fine for development and testing.
How do I know if a connection is using mTLS?
On the server side, if r.TLS.PeerCertificates is not empty and has at least one certificate, the client authenticated with mTLS. On the client side, if you load a certificate into the TLS config and send it, you're using mTLS. You can also use openssl s_client and look for "Client certificate requested" in the output.
What is the difference between ClientAuth: tls.RequestClientCert and tls.RequireAndVerifyClientCert?
RequestClientCert means "ask the client for a certificate, but allow the handshake to succeed if the client doesn't send one." RequireAndVerifyClientCert means "demand a certificate and fail the handshake if one is not provided or is invalid." For security-critical paths (microservices), use RequireAndVerifyClientCert.
Can I verify the client certificate's CN instead of relying on the CA pool?
Not recommended. Always verify using the CA chain (ClientCAs pool). If you manually check the CN, you bypass Go's robust certificate validation logic and can introduce vulnerabilities. Trust the standard library's verification.
How do I handle certificate expiration?
Monitor certificate expiration dates and rotate them before they expire. In Kubernetes, cert-manager automates this. For manual systems, set a calendar reminder 30 days before expiration. When you rotate, generate new certificates, deploy them to your services, and restart the services to pick up the new certificates.
Is mTLS the same as mutual authentication?
mTLS is mutual authentication at the transport layer using certificates. There are other forms of mutual authentication (API keys, OAuth) at the application layer. mTLS specifically refers to mutual certificate-based TLS.
Top comments (1)
Thanks I'm big fan of TLS. worth mentioning that the server have tow trust both the client certificate as well as tge the client root CA. Otherwise the server eould allow/permit any client with valid certificate from the same Client CA. try to post another version with intermediate CA in the picture that is usually the case in production. The servername parameter for openssl is super important for a reverse proxy serving multiple secure backend and I also learned recently that this message the initial one is not encrypted. thanks again. hope this helps someone in the future.