<aside> 🧭

Module 12 · mTLS & Internal PKI

Every module so far has answered one question: is this server really who it says it is? The server never asked the same question back. It just took your password and hoped.

This module turns the check around. The client presents a certificate too, so both sides prove who they are before a single byte of data moves. Then it covers the harder half — how you hand out and replace thousands of those certificates without it becoming a full-time job.

🧠 concept → 🧪 exercise → ✅ expected result (hidden) → 🎯 interview questions (answers hidden)

Prerequisite: Modules 01–11. You need Extended Key Usage from Module 03 (C4), your own CA from Module 05, the handshake from Module 06, the openssl verify error codes from Module 07, nginx from Module 08, CRLs from Module 09, and Module 11's conclusion that internal services do not belong on public certificates.

</aside>


<aside> 🚪

The picture to hold in your head for this whole module — the door that checks you back.

So far, TLS has worked like walking into a bank. You check the bank is real: the sign on the door, the branding, the address. That is the server certificate.

Then you walk up to the counter and prove who you are with a password. A password is a secret you say out loud. Anyone who overhears it, or tricks you into saying it at the wrong counter, can now be you.

mTLS replaces that with a staff pass. You do not say anything. You hold up a card that was issued to you, and the card proves itself using a private key that never leaves your pocket. Nothing is spoken, so nothing can be overheard.

And the door is fussy. It checks who issued the card, whether the card is the right type for this door, whether it has expired, and whether it has been reported lost. That is the whole of Part A.

</aside>

<aside> 🖥️

Build the lab. Everything in Parts A–C runs offline inside ~/tls-lab/m12/. You will build two small CAs, issue a few certificates, and run a local web server on a high port. Nothing touches your system trust store.

mkdir -p ~/tls-lab/m12 && cd ~/tls-lab/m12

Part C uses nginx. If you do not have it, install it (sudo apt-get install -y nginx / brew install nginx) — or skip the nginx exercises and read the outputs, which were all captured from a real run. On a work laptop, installing nginx is normally fine because we never start the system service; we run it by hand with our own config file and stop it again.

To stop everything and clean up at any time:

pkill -f 'nginx -c /tmp' 2>/dev/null; pgrep -f 'openssl s_server' | xargs -r kill
rm -rf ~/tls-lab/m12

All output in this module was produced on OpenSSL 3.0.13, nginx 1.24.0 and curl 8.5.0.

</aside>


Part A · What mTLS actually is

A1 · One-way TLS proves the server — mTLS proves both sides

<aside> 📖

Official docs: RFC 8446 §4.4.2 — Certificate · RFC 8446 §4.3.2 — Certificate Request · Cloudflare — What is mTLS?

</aside>

<aside> 🎫

The analogy — the password versus the staff pass.

A password is something you say. To use it you must reveal it. The other side must store something derived from it. If they are careless, or if you say it to the wrong person, it is gone.

A staff pass is something you hold. You never hand it over and you never read it out. It proves itself, and the thing that makes it work — the chip inside — never leaves the card.

That is exactly the difference between a bearer token and a client certificate. A client certificate is never sent as a secret, because it is not a secret. The secret is the private key, and the private key stays on the client machine forever.

</aside>

Here is the difference in one table.

Ordinary TLS mTLS
Server proves itself Yes — sends a certificate Yes — sends a certificate
Client proves itself No. The connection is set up first, then the app asks for a password or token Yes — sends a certificate too, during the handshake
When identity is known After the connection, inside the application Before the connection completes. An unknown client never gets to send a request
What is sent over the wire The secret itself — password, API key, bearer token A public certificate plus a signature. The private key is never transmitted
If the secret leaks Anyone holding it is you, anywhere, until it is rotated There is nothing to leak in transit. Stealing the identity means stealing a file from a machine

<aside> 🔑

The sentence that makes mTLS click: the check happens before the application exists.

With a password, the attacker gets a full TCP connection, a completed TLS handshake, and the right to send requests at your login page all day.

With mTLS, a client with no valid certificate is dropped during the handshake. There is no request, no login page, no rate limit to defeat, and usually no log line in the application at all — because from the application's point of view nothing ever happened.

That is why mTLS is the default for service-to-service traffic. It moves the front door outwards, in front of everything.

</aside>

🧪 Exercise A1.1 — Build the two things every mTLS setup needs: a server and a way to talk to it

cd ~/tls-lab/m12

# a CA that signs SERVER certificates
openssl genpkey -algorithm RSA -pkeyopt rsa_keygen_bits:2048 -out server-ca.key
openssl req -x509 -new -key server-ca.key -sha256 -days 3650 -out server-ca.crt \
  -subj "/CN=Lab Server CA" \
  -addext "basicConstraints=critical,CA:TRUE" \
  -addext "keyUsage=critical,keyCertSign,cRLSign"

# the server's own certificate
openssl genpkey -algorithm RSA -pkeyopt rsa_keygen_bits:2048 -out server.key
openssl req -new -key server.key -out server.csr -subj "/CN=api.lab.test"
cat > srv.cnf <<'EOF'
basicConstraints=critical,CA:FALSE
keyUsage=critical,digitalSignature,keyEncipherment
extendedKeyUsage=serverAuth
subjectAltName=DNS:api.lab.test,DNS:localhost,IP:127.0.0.1
EOF
openssl x509 -req -in server.csr -CA server-ca.crt -CAkey server-ca.key -CAcreateserial \
  -days 90 -sha256 -extfile srv.cnf -out server.crt

# start it, with NO client authentication at all
nohup openssl s_server -accept 4433 -cert server.crt -key server.key -www -quiet > s1.log 2>&1 &
SRV=$!; sleep 1

curl -s --cacert server-ca.crt --resolve api.lab.test:4433:127.0.0.1 \
  <https://api.lab.test:4433/> -o /dev/null -w "no client cert -> HTTP %{http_code}\n"

kill $SRV

🎯 Interview questions — What mTLS is