A self-contained lab for understanding and testing mutual TLS (mTLS) using only Python's standard library and OpenSSL. No external certificate authorities, no cloud services — everything runs on localhost.
- Prerequisites
- Folder Structure
- Certificate Creation
- Server Setup
- Client Setup
- Testing Scenarios
- Security Explanation
- Python 3.10+ (uses
ssl.TLSVersionenum, introduced in 3.7; type uniondict | Nonerequires 3.10) - No third-party packages required — uses
http.server,ssl,urllib.requestfrom the standard library only
Verify:
python3 --version # must be >= 3.10- OpenSSL 1.1.1+ or LibreSSL 3.3+ (macOS ships LibreSSL; either works)
Verify:
openssl version # e.g. OpenSSL 3.x or LibreSSL 3.x- macOS (tested on macOS 14+) or Linux (Ubuntu 22.04+, Fedora 38+) or Windows 11
- A POSIX shell (
bash,zsh,powershell) for running the cert generation script
mtls-lab/
├── certs/ # All X.509 certificates, keys, and OpenSSL configs
│ ├── ca.cnf # CA certificate configuration
│ ├── ca.crt # CA self-signed certificate (trust anchor)
│ ├── ca.key # CA private key ← keep secret, never share
│ ├── ca.srl # CA serial number file (auto-generated)
│ ├── server.cnf # Server certificate configuration (includes SAN)
│ ├── server.crt # Server certificate (signed by CA)
│ ├── server.key # Server private key
│ ├── server.csr # Server certificate signing request (intermediate)
│ ├── client.cnf # Client certificate configuration
│ ├── client.crt # Client certificate (signed by CA)
│ ├── client.key # Client private key
│ ├── client.pfx # Client PFX: a container that includes the certificate, private key, and full certificate chain, prepared for import locally or into IvSign.
│ ├── client.csr # Client CSR (intermediate)
│ └── gen_certs.sh # Script that generates all of the above
├── server/
│ └── server.py # mTLS HTTPS server (GET /todo, POST /todo)
└── client/
├── client.py # mTLS HTTPS client (POST /todo, then GET /todo)
└── client_no_cert.py # Negative test — connection without client cert
Security note: In a real environment, private keys (
*.key) must never be committed to version control. Addcerts/*.keyto.gitignore.
| Extension | Purpose | What breaks without it |
|---|---|---|
| SAN (Subject Alternative Name) | Binds the certificate to hostnames/IPs the server actually runs on | Modern TLS stacks (Python ssl, Go, Chrome) reject certs without SAN even if CN matches |
| EKU serverAuth | Signals that the cert may be used to authenticate a server | Python's ssl module will reject a server cert without this OID |
| EKU clientAuth | Signals that the cert may be used to authenticate a client | Servers enforcing mTLS will reject a client cert without this OID |
# From the project root
bash certs/gen_certs.shThe script runs all 6 steps below in sequence and verifies the certificate chains at the end.
openssl genrsa -out certs/ca.key 4096- Generates a 4096-bit RSA key (larger than server/client keys — the CA is the root of trust)
- In production, protect this key with a passphrase (
-aes256) and store it offline
openssl req \
-new -x509 \
-days 3650 \
-key certs/ca.key \
-out certs/ca.crt \
-config certs/ca.cnf-x509skips the CSR step and self-signs directly — appropriate only for a root CAbasicConstraints = CA:truemarks this as a CA certificate; without it, browsers and TLS stacks will refuse to use it as a trust anchor- 3650 days (≈10 years) is reasonable for a lab CA; production CAs should have shorter validity
openssl genrsa -out certs/server.key 2048
openssl req \
-new \
-key certs/server.key \
-out certs/server.csr \
-config certs/server.cnfThe server.cnf file embeds the SAN via req_extensions:
[ req_ext ]
subjectAltName = @alt_names
extendedKeyUsage = serverAuth
[ alt_names ]
DNS.1 = localhost
IP.1 = 127.0.0.1openssl x509 \
-req \
-days 365 \
-in certs/server.csr \
-CA certs/ca.crt \
-CAkey certs/ca.key \
-CAcreateserial \
-out certs/server.crt \
-extensions v3_server \
-extfile certs/server.cnfCritical: The
-extensions v3_server -extfile certs/server.cnfflags copy the SAN and EKU extensions from the config into the signed certificate. Without these flags, the extensions stay in the CSR only and are not included in the final cert.
openssl genrsa -out certs/client.key 2048
openssl req \
-new \
-key certs/client.key \
-out certs/client.csr \
-config certs/client.cnfThe client.cnf requires only clientAuth (no SAN needed — clients are identified by CN, not by hostname):
[ req_ext ]
extendedKeyUsage = clientAuthopenssl x509 \
-req \
-days 365 \
-in certs/client.csr \
-CA certs/ca.crt \
-CAkey certs/ca.key \
-CAcreateserial \
-out certs/client.crt \
-extensions v3_client \
-extfile certs/client.cnfopenssl pkcs12 -export \
-out certs/client.pfx \
-inkey certs/client.key \
-in certs/client.crt \
-certfile certs/ca.crtThis bundles the client certificate, the client private key, and the CA certificate into a single .pfx file.
OpenSSL will prompt you for an export password to protect the .pfx file.
# Inspect the server cert — look for SAN and EKU sections
openssl x509 -text -noout -in certs/server.crt
# Key fields to verify in the output:
# X509v3 Subject Alternative Name:
# DNS:localhost, IP Address:127.0.0.1
# X509v3 Extended Key Usage:
# TLS Web Server Authentication
# Inspect the client cert
openssl x509 -text -noout -in certs/client.crt
# Key fields to verify:
# X509v3 Extended Key Usage:
# TLS Web Client Authentication
# Verify certificate chains
openssl verify -CAfile certs/ca.crt certs/server.crt
# => certs/server.crt: OK
openssl verify -CAfile certs/ca.crt certs/client.crt
# => certs/client.crt: OKNo external packages are required. The server uses only the Python standard library:
http.server — BaseHTTPRequestHandler, HTTPServer
ssl — SSLContext, CERT_REQUIRED, TLSVersion
json — request/response serialization
pathlib — certificate path resolution
ctx = ssl.SSLContext(ssl.PROTOCOL_TLS_SERVER)
# ① Require client certificate — this is the "mutual" in mTLS
ctx.verify_mode = ssl.CERT_REQUIRED
# ② Load the CA that signed the client cert — only certs from this CA are accepted
ctx.load_verify_locations(cafile="certs/ca.crt")
# ③ Load server's own cert + key — sent to the client during the handshake
ctx.load_cert_chain(certfile="certs/server.crt", keyfile="certs/server.key")
# ④ Reject TLS 1.0 and 1.1 (deprecated per RFC 8996)
ctx.minimum_version = ssl.TLSVersion.TLSv1_2| Flag | Effect |
|---|---|
CERT_REQUIRED |
Server demands a client certificate; handshake fails if none is provided |
CERT_OPTIONAL |
Server accepts but does not require a client cert |
CERT_NONE |
Server ignores client certificates entirely (standard one-way TLS) |
After the handshake, self.connection.getpeercert() returns a dictionary with the client's certificate fields:
cert = self.connection.getpeercert()
# Example:
# {
# 'subject': ((('commonName', 'mtls-client'),),,),
# 'issuer': ((('commonName', 'mTLS Lab Root CA'),),,),
# 'notAfter': 'Feb 25 00:00:00 2026 GMT',
# ...
# }The server now exposes two authenticated endpoints:
| Method | Path | Description |
|---|---|---|
GET |
/todo |
Returns the current TODO list |
POST |
/todo |
Creates a new TODO item and returns it |
POST /todo expects a JSON body like this:
{
"task": "Añadir endpoint POST",
"done": false
}Validation rules:
taskmust be a non-empty stringdoneis optional and defaults tofalse- if provided,
donemust be a boolean
On success, the server responds with HTTP 201 Created:
{
"client": "mtls-client",
"message": "TODO item created",
"todo": {
"id": 6,
"task": "Añadir endpoint POST",
"done": false
}
}GET /todo returns the authenticated client CN plus the current list:
{
"client": "mtls-client",
"todos": [
{
"id": 1,
"task": "Set up local CA",
"done": true
}
]
}python3 server/server.pyExpected output:
[SERVER] mTLS server listening on https://127.0.0.1:8443
[SERVER] Requiring client cert signed by: ca.crt
[SERVER] Press Ctrl+C to stop.
Build the image from the project root using the Dockerfile:
docker build -f Dockerfile -t <dockerhub-user>/mtls-server:latest .Run the container exposing the HTTPS port:
docker run -d --rm --name mtls-server -p 8443:8443 <dockerhub-user>/mtls-server:latestThe container sets MTLS_SERVER_HOST=0.0.0.0 by default so the service is reachable through Docker port publishing.
To publish the image to Docker Hub:
docker push <dockerhub-user>/mtls-server:latestNo external packages required. Uses urllib.request with a custom HTTPSHandler.
ctx = ssl.SSLContext(ssl.PROTOCOL_TLS_CLIENT)
# ① Verify server cert against our local CA (not the OS trust store)
ctx.verify_mode = ssl.CERT_REQUIRED
ctx.check_hostname = True
ctx.load_verify_locations(cafile="certs/ca.crt")
# ② Present client cert + key during the TLS handshake
ctx.load_cert_chain(certfile="certs/client.crt", keyfile="certs/client.key")The custom context is injected into urllib.request via:
handler = urllib.request.HTTPSHandler(context=ctx)
opener = urllib.request.build_opener(handler)
opener.open(url)The client now performs two authenticated requests in sequence:
POST /todo— creates a new TODO itemGET /todo— fetches the updated TODO list including the newly created item
Open a second terminal (server must be running):
python3 client/client.pyTerminal 1 — Start the server:
python3 server/server.pyTerminal 2 — Run the client:
python3 client/client.pyExpected client output:
[CLIENT] Connecting to https://127.0.0.1:8443/todo
[CLIENT] Client cert: client.crt
[CLIENT] CA trust: ca.crt
[CLIENT] Creating new TODO with POST /todo ...
[CLIENT] POST response (HTTP 201):
{
"client": "mtls-client",
"message": "TODO item created",
"todo": {
"id": 6,
"task": "Añadir endpoint POST",
"done": false
}
}
[CLIENT] Fetching TODO list with GET /todo ...
[CLIENT] Authenticated as: "mtls-client"
[CLIENT] Response (HTTP 200):
{
"client": "mtls-client",
"todos": [
{"id": 1, "task": "Set up local CA", "done": true},
{"id": 2, "task": "Issue server certificate", "done": true},
{"id": 3, "task": "Issue client certificate", "done": true},
{"id": 4, "task": "Configure mTLS server", "done": true},
{"id": 5, "task": "Validate mutual auth", "done": false},
{"id": 6, "task": "Añadir endpoint POST", "done": false}
]
}
Expected server output (stderr):
[SERVER] REQUEST | peer=127.0.0.1 | cert-CN='mtls-client' | "POST /todo HTTP/1.1" 201 -
[SERVER] AUTHENTICATED | CN='mtls-client' | creating TODO
[SERVER] TODO CREATED | CN='mtls-client' | item={'id': 6, 'task': 'Añadir endpoint POST', 'done': False}
[SERVER] REQUEST | peer=127.0.0.1 | cert-CN='mtls-client' | "GET /todo HTTP/1.1" 200 -
[SERVER] AUTHENTICATED | CN='mtls-client' | serving /todo
You can also test the new endpoint manually:
curl --cert certs/client.crt \
--key certs/client.key \
--cacert certs/ca.crt \
-X POST https://127.0.0.1:8443/todo \
-H "Content-Type: application/json" \
-d '{"task":"Añadir endpoint POST","done":false}'Expected response:
{
"client": "mtls-client",
"message": "TODO item created",
"todo": {
"id": 6,
"task": "Añadir endpoint POST",
"done": false
}
}Terminal 2 — Run the negative test:
python3 client/client_no_cert.pyExpected client output:
[NEGATIVE TEST] Attempting connection WITHOUT client certificate...
[NEGATIVE TEST] Target: https://127.0.0.1:8443/todo
[NEGATIVE TEST] Connection rejected as expected.
[NEGATIVE TEST] Error: [SSL: TLSV13_ALERT_CERTIFICATE_REQUIRED] tlsv13 alert certificate required (_ssl.c:...)
[NEGATIVE TEST] PASS: Server correctly refused unauthenticated client.
Why it fails:
The server's ssl.CERT_REQUIRED setting instructs OpenSSL to send a CertificateRequest message during the TLS handshake (in the ServerHello phase). When the client responds with an empty Certificate message (no cert), the server sends a TLS alert — either:
certificate_required(TLS 1.3, RFC 8446 §6.2)handshake_failure(TLS 1.2)
The TCP connection is torn down before any HTTP data is exchanged. Neither the POST /todo nor the GET /todo request is ever sent.
Verifying via OpenSSL s_client (alternative negative test):
# Without client cert — should fail
openssl s_client \
-connect 127.0.0.1:8443 \
-CAfile certs/ca.crt
# With client cert — should succeed
openssl s_client \
-connect 127.0.0.1:8443 \
-CAfile certs/ca.crt \
-cert certs/client.crt \
-key certs/client.keyClient Server
| |
|──── ClientHello (supported ciphers) ────>|
| |
|<─── ServerHello ─────────────────────────|
|<─── Certificate (server cert) ───────────|
|<─── CertificateRequest ──────────────────| ← mTLS only
|<─── ServerHelloDone ─────────────────────|
| |
|──── Certificate (client cert) ──────────>| ← mTLS only
|──── CertificateVerify (signed proof) ───>| ← mTLS only
|──── Finished ────────────────────────────>|
| |
|<─── Finished ────────────────────────────|
| |
|==== Encrypted application data ==========| ← POST /todo, GET /todo
Where client authentication occurs: Between CertificateRequest and Finished. The client signs a digest of the handshake transcript using its private key (CertificateVerify). The server verifies the signature against the public key in the client certificate, then verifies the certificate chain back to the trusted CA.
Why private keys never leave their side:
The private key is used only to sign (CertificateVerify). The signature can be verified by anyone with the corresponding public key (embedded in the certificate), but the private key itself is never transmitted. This is the asymmetry that makes public-key cryptography secure.
| Mistake | Consequence | Fix |
|---|---|---|
| Missing SAN in server cert | Python ssl raises ssl.CertificateError: hostname mismatch |
Add subjectAltName to server cert config |
Missing clientAuth EKU in client cert |
Server rejects cert during handshake | Add extendedKeyUsage = clientAuth to client cert config |
| SAN in CSR but not in signed cert | Extensions silently dropped | Always pass -extensions … -extfile … to openssl x509 -req |
CERT_OPTIONAL instead of CERT_REQUIRED |
Clients can connect without any certificate | Use CERT_REQUIRED on the server |
| Using system CA store for client verification | Client accepts any CA-signed server cert | Explicitly load_verify_locations(cafile=…) with your CA only |
| Committing private keys to git | Key compromise | Add certs/*.key to .gitignore |
| Sharing one cert between all clients | Cannot revoke individual clients | Issue one certificate per client identity |
Not checking getpeercert() fields |
Any cert from the trusted CA is accepted | Validate CN, OU, or SAN fields to enforce authorization, not just authentication |
mTLS provides authentication (who are you?) — it proves the client holds the private key for a certificate signed by the trusted CA. It does not automatically provide authorization (what are you allowed to do?). In production, always check the certificate subject fields after the handshake to enforce access control at the application layer.
In this lab, TODO items are stored in memory only. That means items created with POST /todo are lost when the server restarts. For a production service, you would persist them in a database or file-backed store.