Mutual TLS Explained: Making the Client Prove Its Identity Too

This post is part of Protocol Lab, a free, hands-on series for learning networking protocols by building and breaking them in a container lab. All the lab material — topologies, configs, and scripts — lives in the repo: github.com/pathvector-studio/protocol-lab.

In Lab #09, only the server proved who it was. In many real systems — service meshes, VPNs, API gateways, database clients — the client must prove its identity too. That is mutual TLS (mTLS).

You will run one small lab CA that signs both a server certificate and a client certificate, then watch two outcomes against a server started with -Verify (client certificate required):

  • A client that presents its certificate completes the handshake — both ends report Verify return code: 0 (ok).
  • A client with no certificate is rejected during the handshake with a TLS alert: certificate required.

Reading guide: rfc-notes/tls-mutual-tls.md

Prerequisite: TLS Lab 09: What Is Visible Before Encryption

Expected time: 45–60 minutes.

The Goal

By the end, you should be able to fill in this table:

Client Server (-Verify) sends Outcome
presents a CA-signed cert CertificateRequest → verifies the client handshake OK, both sides Verify return code: 0
presents no cert CertificateRequest → gets nothing back tlsv13 alert certificate required

What You Will Learn

  • The difference between server-only TLS (Lab 09) and mutual TLS.
  • What a CertificateRequest is, and how the server advertises which CAs it trusts.
  • Why both the server cert and the client cert must chain to a CA the other side trusts.
  • What openssl s_server -Verify (mandatory) vs -verify (optional) means.
  • Why, in TLS 1.3, the CertificateRequest and the client's certificate are encrypted (you read them at the endpoint, not from the wire).

This lab does not cover:

  • A real public PKI or certificate lifecycle (issuance, revocation, OCSP).
  • SPIFFE/SVID, service-mesh identity, or automatic cert rotation.
  • Client-cert authorization logic beyond "is it signed by our CA".

Where to Read in the RFCs

RFC Section What to focus on
RFC 8446 4.3.2 CertificateRequest (the server demanding a client certificate)
RFC 8446 4.4.2 The Certificate message (sent encrypted in TLS 1.3)
RFC 8446 4.4.3 CertificateVerify (proof of possessing the private key)
RFC 8446 2 The full 1-RTT handshake (a review of Lab 09)
RFC 5280 3, 4 X.509 certificates, CA signatures, and chains
RFC 5737 3 Confirming the names used here are documentation-only

The Big Picture

One client, one server. A dedicated CA signs certificates for both. The server waits with client certificates set to mandatory.

                 Protocol Lab CA
                 /            \
        signs server.crt    signs client.crt
             |                    |
client (10.0.0.1) ------ eth1 ------ server (10.0.0.2:4433)
  openssl s_client                    openssl s_server -Verify 1
  (1) with client.crt/key  -> OK
  (2) without a cert       -> rejected

Because the server runs with -Verify (mandatory), it sends a CertificateRequest mid-handshake and validates the client's certificate against the CA. If the client presents no certificate, the server cuts the handshake off with certificate required.

sequenceDiagram
  participant C as client
  participant S as server (-Verify)

  Note over C,S: (1) client HAS a cert
  C->>S: ClientHello
  S->>C: ServerHello, {Certificate=server.crt}, {CertificateRequest}
  C->>S: {Certificate=client.crt}, {CertificateVerify}, {Finished}
  Note over S: verifies client.crt against the CA
  S->>C: {Finished}  (Verify return code: 0 on both sides)

  Note over C,S: (2) client has NO cert
  C->>S: ClientHello
  S->>C: ServerHello, {Certificate}, {CertificateRequest}
  C->>S: {Certificate = empty}, {Finished}
  Note over S: no client cert -> reject
  S-->>C: alert: certificate required

Note: Everything here runs inside the container lab with documentation-style names, so nothing in this lab touches the real internet or any public PKI.

What You Need

Recommended environment:

  • Linux / WSL2 / a Linux VM
  • Docker
  • containerlab

Images used:

  • nicolaka/netshoot:latest — bundles openssl, tcpdump, and tshark.

No additional images are required. All certificates (CA, server, client) are generated inside the server container by run.sh — nothing is committed to the repo.

Running the Lab

The quick path, which deploys, generates the CA and both certificates, starts the mTLS server, runs the with-cert client (success) and the no-cert client (rejection), verifies, and tears down for you:

./scripts/labctl.sh run tls-15

Or step through it manually:

1. Move into the working directory

cd protocol-lab/examples/tls-15

2. Deploy and create the certificates

sudo containerlab deploy -t tls-15.clab.yml
# dedicated CA -> server certificate / client certificate
docker exec clab-tls-15-server sh -c '
  cd /tmp
  openssl req -x509 -newkey rsa:2048 -nodes -keyout ca.key -out ca.crt -subj "/CN=Protocol Lab CA" -days 3650
  for who in server client; do
    openssl req -newkey rsa:2048 -nodes -keyout $who.key -out $who.csr -subj "/CN=$who.example.lab"
    openssl x509 -req -in $who.csr -CA ca.crt -CAkey ca.key -CAcreateserial -out $who.crt -days 3650 \
      -extfile <(printf "subjectAltName=DNS:%s.example.lab" "$who")
  done'
# hand ca.crt / client.crt / client.key to the client (run.sh distributes them via docker cp)

3. Start the mTLS server

docker exec -d clab-tls-15-server sh -c \
  "openssl s_server -accept 4433 -cert /tmp/server.crt -key /tmp/server.key \
     -CAfile /tmp/ca.crt -Verify 1 -tls1_3 -www"

-Verify 1 is the crux: it makes the client certificate mandatory (lowercase -verify requests one but treats it as optional).

4. Connect with a certificate (success)

docker exec clab-tls-15-client sh -c \
  "echo Q | openssl s_client -connect 10.0.0.2:4433 \
     -cert /tmp/client.crt -key /tmp/client.key -CAfile /tmp/ca.crt -tls1_3"

What to look for:

Acceptable client certificate CA names
CN=Protocol Lab CA
New, TLSv1.3, Cipher is TLS_AES_256_GCM_SHA384
Verify return code: 0 (ok)
  • Acceptable client certificate CA names: proof that the server sent a CertificateRequest, saying "present a client certificate signed by one of these CAs."
  • Verify return code: 0 (ok): the client's verification of the server certificate passed — and the server verified and accepted the client certificate on its side too.

5. Connect without a certificate (rejection)

docker exec clab-tls-15-client sh -c \
  "echo Q | openssl s_client -connect 10.0.0.2:4433 -CAfile /tmp/ca.crt -tls1_3"

What to look for:

... tlsv13 alert certificate required ... SSL alert number 116

The client cannot present a certificate, so the server aborts the handshake. A client that cannot prove its identity does not get in.

Expected Output

  • With a certificate: Acceptable client certificate CA names, TLSv1.3, and Verify return code: 0 (ok).
  • Without a certificate: the handshake fails with tlsv13 alert certificate required (alert 116).

Why It Works

Ordinary TLS (Lab 09) only answers one question: "may the client trust the server?" mTLS adds the symmetric question — "may the server trust the client?" — on top.

  • CertificateRequest. A message the server sends mid-handshake: "give me a client certificate — and here are the CAs I trust." With -Verify (mandatory), any client that doesn't comply is rejected.
  • Chain verification in both directions. The client checks that the server certificate chains to a CA it trusts; the server checks that the client certificate chains to a CA it trusts. In this lab a single CA signs both, so it works as long as both ends trust that one CA.
  • CertificateVerify. Presenting a certificate alone is not enough — certificates are public information. Each side proves it holds the corresponding private key by signing over the handshake transcript. Without this, anyone could impersonate a certificate holder.
  • Encrypted in TLS 1.3. Keys are derived right after ServerHello, so both the CertificateRequest and the client's Certificate are sent encrypted. A passive capture on the wire cannot read them; only the connection's endpoints (like these openssl processes) can.

The key insight: mTLS is symmetric TLS — the peer must prove its identity with a certificate too, and a peer that can't prove itself is rejected at the connection stage.

Common Pitfalls

  • Confusing server authentication with client authentication. Lab 09 is server-only; mTLS is both.
  • Mixing up -Verify and -verify. Uppercase is mandatory (reject if absent); lowercase is optional (request it, but let the client through without one).
  • Both ends must trust the same CA. Sign the client certificate with a CA the server doesn't know, and it gets rejected.
  • Thinking the certificate alone is enough. CertificateVerify (proof of the private key) is also required.
  • Expecting to see the client certificate in a capture. In TLS 1.3 it's encrypted — confirm it in the endpoint output instead.
  • echo Q. This input closes openssl s_client right after the handshake. Without it, the connection stays open and waits.

Cleanup

sudo containerlab destroy -t tls-15.clab.yml --cleanup

If you used labctl.sh run tls-15, the script runs destroy for you at the end.

Check Your Understanding

  1. What is the difference between ordinary TLS (Lab 09) and mTLS? What gets added?
  2. Who sends the CertificateRequest message, and what does it demand?
  3. What is the difference between -Verify and -verify in openssl s_server?
  4. If the client certificate were signed by a different CA, what would this server do, and why?
  5. Why is holding a certificate not enough — why is CertificateVerify also required?
  6. In TLS 1.3, why can't you read the client certificate from a passive capture?

References

Verified Run Log (2026-07-07)

This lab has been confirmed reproducible on real hardware.

Environment:

  • Ubuntu 26.04 LTS (kernel 7.0.0-27-generic, x86_64)
  • Docker 29.1.3
  • containerlab 0.77.0
  • client / server: nicolaka/netshoot:latest (openssl 3.x)

Running PATH="/tmp/pl-shim:$PATH" ./scripts/labctl.sh run tls-15 performed deploy → verify → destroy, and verification.json returned "status": "verified". A dedicated CA (CN=Protocol Lab CA) signed both the server and client certificates.

The client with a certificate (success, mutual authentication)

$ echo Q | openssl s_client -connect 10.0.0.2:4433 \
    -cert /tmp/client.crt -key /tmp/client.key -CAfile /tmp/ca.crt -tls1_3

depth=1 CN=Protocol Lab CA
 1 s:CN=Protocol Lab CA
   i:CN=Protocol Lab CA
Acceptable client certificate CA names
CN=Protocol Lab CA
New, TLSv1.3, Cipher is TLS_AES_256_GCM_SHA384
Verify return code: 0 (ok)

Acceptable client certificate CA names is the evidence that the server sent a CertificateRequest. Verify return code: 0 (ok) shows verification passed in both directions.

The client without a certificate (rejected)

$ echo Q | openssl s_client -connect 10.0.0.2:4433 -CAfile /tmp/ca.crt -tls1_3

...:error:0A00045C:SSL routines:ssl3_read_bytes:tlsv13 alert certificate required:...:SSL alert number 116

The client can't present a certificate, so the server aborts the handshake with tlsv13 alert certificate required (alert 116). This is exactly the difference from Lab 09 (server-only authentication): with mTLS, a client that cannot prove its identity cannot connect.

Cleanup

containerlab destroy -t tls-15.clab.yml --cleanup

That's mutual TLS: the same handshake you already know, made symmetric — the server demands a certificate too, verifies it against a CA it trusts, and drops anyone who can't prove who they are before a single byte of application data flows.

Explore the full Protocol Lab series here: github.com/pathvector-studio/protocol-lab. If these labs are useful to you, please ⭐ star the repo on GitHub — it genuinely helps others find the project.

Next up, we'll keep climbing the TLS stack and look at what certificate lifecycles — expiry, rotation, and revocation — mean in practice.

Read more