Originally published at https://blog.pathvector.dev/protocol-lab-tls-15/ — part of the free Protocol Lab series.
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, andVerify 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
-Verifyand-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
- What is the difference between ordinary TLS (Lab 09) and mTLS? What gets added?
- Who sends the CertificateRequest message, and what does it demand?
- What is the difference between
-Verifyand-verifyinopenssl s_server? - If the client certificate were signed by a different CA, what would this server do, and why?
- Why is holding a certificate not enough — why is CertificateVerify also required?
- In TLS 1.3, why can't you read the client certificate from a passive capture?
References
- RFC 8446: The Transport Layer Security (TLS) Protocol Version 1.3
- RFC 5280: Internet X.509 Public Key Infrastructure Certificate and CRL Profile
- RFC 5737: IPv4 Address Blocks Reserved for Documentation
- openssl s_server manual page
- openssl s_client manual page
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.
Top comments (0)