How to Build a Private Certificate Authority and Enforce Mutual TLS in Python
Build a private certificate authority with the cryptography package, require client certificates in a standard-library Python server, and learn to read the errors when a name, a purpose, a clock, or...
When you open a website over HTTPS, your browser checks the website’s certificate, but the website knows nothing about you until you log in. That is fine for the public web and a poor fit for internal services. A billing service that calls an invoice API should not have to carry a password that can leak into a log file. Mutual TLS (mTLS) fixes this by making the check go both ways: the server also demands a certificate, so the connection itself proves who is calling before a single request is read.
Table Of Content
- The ideas in five minutes
- Prerequisites
- Step 0: Set up the project and check your versions
- Step 1: Build the certificate authority
- Step 2: Issue a server certificate and two client certificates
- Step 3: Write the mutual-TLS server and client
- Step 4: Break the trust on purpose
- 4.1 The caller presents no certificate
- 4.2 TLS 1.3 tells the client later than TLS 1.2 does
- 4.3 The certificate comes from a different CA
- 4.4 The server forgets to ask for a certificate
- Step 5: Break the certificates on purpose
- 5.1 Names: what the certificate says it is for
- 5.2 Purpose: what the key is allowed to do
- 5.3 Time: when the certificate is valid
- 5.4 Strictness: the Python 3.13 change that breaks hand-made CAs
- Step 6: Revocation is opt-in
- A field guide to the errors
- Step 7: Check the server with a client that is not Python
- Step 8: Lock the behaviour in with tests
- Verify the whole thing end to end
- Mistakes to avoid
- Where to go next
In this tutorial you will build the whole thing by hand in Python, so that nothing is magic. You will create a private certificate authority (CA) with the cryptography package, issue certificates to a server and to two callers, write a small HTTPS server in the standard library that accepts only callers holding one of your certificates and decides what each of them may do, and then break the setup on purpose, one property at a time, in more than a dozen ways. TLS errors are famously cryptic, and the fastest way to understand them is to cause each one yourself and read both sides of the conversation. At the end you will lock the behaviour in with a 16-test pytest suite.
The ideas in five minutes
A certificate is a small signed document that says a public key belongs to a name. It also carries a validity period, a list of things the key may be used for, and the signature of whoever vouches for it.
A certificate authority is the party that signs certificates. Anyone who trusts the CA’s own certificate can check any certificate the CA signed. The set of CA certificates a program trusts is its trust store. For public websites the trust store is large and managed by your operating system or browser. For a private service you use a trust store with exactly one entry, your own CA. That is the whole security model: a certificate is accepted only if its signature chains back to a CA in the trust store, and every other field is just a claim until that is true.
Four kinds of field matter in this tutorial. The subject alternative name (SAN) lists the DNS names and IP addresses the certificate is valid for. The extended key usage (EKU) says whether the key may authenticate a server, a client, or both. The validity period says when the certificate starts and stops being usable. The basic constraints and key usage fields mark a certificate as a CA and allow it to sign others. Finally, a certificate revocation list (CRL) is a signed list of certificates the CA no longer vouches for, and you will see that using one is something you must switch on yourself.
Prerequisites
- Python 3.13 or newer. I used 3.13.14, linked against OpenSSL 3.0.21. Step 5 depends on a default that changed in 3.13.
- Any operating system. I ran everything on Windows 11 and I point out the one place where that mattered. No administrator rights are needed, and the servers listen on the loopback address only.
- Two packages:
cryptography(I used 50.0.1, and a clean install of 50.0.2 gave the same results) andpytest. - Optional: the OpenSSL command-line tool, used in Steps 2 and 7. I used OpenSSL 3.5.7, which ships with Git for Windows.
- Comfort with Python functions, classes, and context managers. You do not need to know TLS.
One warning before you start: the private keys in this lab are written to disk without encryption. That is fine for a throwaway lab and wrong for anything real, so never reuse these files.
Step 0: Set up the project and check your versions
Create a folder, a virtual environment, and install the two packages. Activate the environment with .venv\Scripts\activate on Windows, or source .venv/bin/activate on macOS and Linux.
mkdir mtls-lab
cd mtls-lab
python -m venv .venv
python -m pip install cryptography pytest
Now save the following script as step00_versions.py and run it with python step00_versions.py. It prints the versions in play and three facts about the ssl module that explain failures later in this tutorial.
# step00_versions.py
import platform
import ssl
import sys
import cryptography
print("python :", sys.version.split()[0], "on", platform.system())
print("openssl :", ssl.OPENSSL_VERSION)
print("cryptography:", cryptography.__version__)
plain = ssl.SSLContext(ssl.PROTOCOL_TLS_SERVER)
default = ssl.create_default_context()
strict = ssl.VERIFY_X509_STRICT
print("strict flag on SSLContext(PROTOCOL_TLS_SERVER):", bool(plain.verify_flags & strict))
print("strict flag on create_default_context() :", bool(default.verify_flags & strict))
print("verify_mode of create_default_context(Purpose.CLIENT_AUTH):",
repr(ssl.create_default_context(ssl.Purpose.CLIENT_AUTH).verify_mode))
python : 3.13.14 on Windows
openssl : OpenSSL 3.0.21 9 Jun 2026
cryptography: 50.0.1
strict flag on SSLContext(PROTOCOL_TLS_SERVER): False
strict flag on create_default_context() : True
verify_mode of create_default_context(Purpose.CLIENT_AUTH): <VerifyMode.CERT_NONE: 0>
Read the last three lines carefully. A context made by ssl.create_default_context() has the strict-validation flag switched on, a plain SSLContext(PROTOCOL_TLS_SERVER) does not, and a server context made by create_default_context(Purpose.CLIENT_AUTH) has verify_mode set to CERT_NONE. The Python documentation says what that last value means on a server: “In server mode, no certificate is requested from the client, so the client does not send any for client cert authentication.” In other words, a server built the obvious way never asks for a client certificate at all. Step 4 shows what that costs.
Step 1: Build the certificate authority
The CA is a key pair plus a self-signed certificate. Create pki.py with the helpers below. Identity bundles a certificate with its private key and knows how to write both to disk, load reads them back, and san_dns_names lists the DNS names a certificate vouches for, which you will use as a lint check in Step 5.
# pki.py
"""pki.py - a small private certificate authority built on the cryptography package."""
import datetime
import ipaddress
from dataclasses import dataclass
from pathlib import Path
from cryptography import x509
from cryptography.hazmat.primitives import hashes, serialization
from cryptography.hazmat.primitives.asymmetric import ec
from cryptography.x509.oid import ExtendedKeyUsageOID, NameOID
UTC = datetime.timezone.utc
@dataclass
class Identity:
"""A certificate together with its private key."""
cert: x509.Certificate
key: ec.EllipticCurvePrivateKey
def save(self, folder, stem):
"""Write <stem>.pem (certificate) and <stem>.key (private key, unencrypted: lab use only)."""
folder = Path(folder)
folder.mkdir(parents=True, exist_ok=True)
cert_path, key_path = folder / f"{stem}.pem", folder / f"{stem}.key"
cert_path.write_bytes(self.cert.public_bytes(serialization.Encoding.PEM))
key_path.write_bytes(self.key.private_bytes(
serialization.Encoding.PEM,
serialization.PrivateFormat.PKCS8,
serialization.NoEncryption(),
))
return cert_path, key_path
def load(folder, stem):
"""Read an Identity back from <stem>.pem and <stem>.key."""
folder = Path(folder)
cert = x509.load_pem_x509_certificate((folder / f"{stem}.pem").read_bytes())
key = serialization.load_pem_private_key((folder / f"{stem}.key").read_bytes(), password=None)
return Identity(cert, key)
def san_dns_names(cert):
"""The DNS names a certificate vouches for (empty when it has no subjectAltName)."""
try:
san = cert.extensions.get_extension_for_class(x509.SubjectAlternativeName)
except x509.ExtensionNotFound:
return []
return san.value.get_values_for_type(x509.DNSName)
def _name(common_name):
return x509.Name([
x509.NameAttribute(NameOID.ORGANIZATION_NAME, "Lab PKI"),
x509.NameAttribute(NameOID.COMMON_NAME, common_name),
])
def _key_usage(**on):
flags = dict.fromkeys(
["digital_signature", "content_commitment", "key_encipherment", "data_encipherment",
"key_agreement", "key_cert_sign", "crl_sign", "encipher_only", "decipher_only"], False)
flags.update(on)
return x509.KeyUsage(**flags)
Now add the function that makes the CA. Append it to pki.py.
# pki.py (continued)
def make_ca(common_name="Lab Root CA", days=3650, omit=()):
"""Create a self-signed root CA that may sign end-entity certificates but no further CAs.
omit names extensions to leave out on purpose ("key_usage", "ski"); real code never does.
"""
key = ec.generate_private_key(ec.SECP256R1())
now = datetime.datetime.now(UTC)
builder = (
x509.CertificateBuilder()
.subject_name(_name(common_name))
.issuer_name(_name(common_name)) # self-signed: the issuer is the subject
.public_key(key.public_key())
.serial_number(x509.random_serial_number())
.not_valid_before(now - datetime.timedelta(minutes=5))
.not_valid_after(now + datetime.timedelta(days=days))
.add_extension(x509.BasicConstraints(ca=True, path_length=0), critical=True)
)
if "key_usage" not in omit:
builder = builder.add_extension(_key_usage(key_cert_sign=True, crl_sign=True), critical=True)
if "ski" not in omit:
builder = builder.add_extension(x509.SubjectKeyIdentifier.from_public_key(key.public_key()), critical=False)
return Identity(builder.sign(key, hashes.SHA256()), key)
Each part of this function is doing a job, so it is worth reading slowly:
- The key is an elliptic-curve key on the P-256 curve. It is small and fast, and both TLS 1.2 and TLS 1.3 accept ECDSA certificates. RSA would work as well.
- The certificate is self-signed: the issuer is the subject, and it is signed with its own private key at the end of the builder chain. A root CA has nobody above it.
- The validity period starts five minutes in the past, to absorb small clock differences between machines, and ends in ten years.
BasicConstraints(ca=True, path_length=0), marked critical, says this certificate may sign other certificates but only end-entity ones, never another CA. That limit is the path length of zero.KeyUsageallows exactly two things: signing certificates (key_cert_sign) and signing revocation lists (crl_sign).SubjectKeyIdentifieris a short fingerprint of the public key. Certificates signed by this CA will point back to it, which is how software finds the right issuer.- The
omitargument exists only so that Step 5 can remove one extension at a time. Real code never uses it.
Save this as step01_ca.py and run it. It creates the CA, prints what is inside, and writes certs/ca.pem and certs/ca.key.
# step01_ca.py
from pki import make_ca
ca = make_ca()
cert = ca.cert
print("subject:", cert.subject.rfc4514_string())
print("issuer :", cert.issuer.rfc4514_string())
print("valid :", cert.not_valid_before_utc.date(), "to", cert.not_valid_after_utc.date())
for ext in cert.extensions:
print(f"ext : {type(ext.value).__name__:<22} critical={ext.critical}")
cert_path, key_path = ca.save("certs", "ca")
print("wrote :", cert_path, key_path)
subject: CN=Lab Root CA,O=Lab PKI
issuer : CN=Lab Root CA,O=Lab PKI
valid : 2026-10-06 to 2036-10-03
ext : BasicConstraints critical=True
ext : KeyUsage critical=True
ext : SubjectKeyIdentifier critical=False
wrote : certs\ca.pem certs\ca.key
Check that it worked. The subject and issuer are identical, the dates span ten years (yours will show today’s date), and the three extensions are present, with the first two marked critical. Critical means a program that does not understand the extension must reject the certificate instead of ignoring it. Keep ca.key safe in real life: anyone who holds it can mint certificates that your servers will accept.
Step 2: Issue a server certificate and two client certificates
Issuing a certificate means building a new one whose issuer is the CA and signing it with the CA’s key. Append this function to pki.py.
# pki.py (continued)
def issue(ca, common_name, role, *, dns=(), ips=(), eku=None, days=30, not_before=None, omit=()):
"""Issue a leaf certificate signed by ca. role is "server" or "client".
omit names extensions to leave out on purpose ("san", "aki", "ski", "eku", "key_usage").
"""
key = ec.generate_private_key(ec.SECP256R1())
start = not_before or datetime.datetime.now(UTC) - datetime.timedelta(minutes=5)
purposes = eku or [ExtendedKeyUsageOID.SERVER_AUTH if role == "server" else ExtendedKeyUsageOID.CLIENT_AUTH]
names = [x509.DNSName(d) for d in dns] + [x509.IPAddress(ipaddress.ip_address(i)) for i in ips]
builder = (
x509.CertificateBuilder()
.subject_name(_name(common_name))
.issuer_name(ca.cert.subject)
.public_key(key.public_key())
.serial_number(x509.random_serial_number())
.not_valid_before(start)
.not_valid_after(start + datetime.timedelta(days=days))
.add_extension(x509.BasicConstraints(ca=False, path_length=None), critical=True)
)
extensions = [
("san", x509.SubjectAlternativeName(names) if names else None, False),
("key_usage", _key_usage(digital_signature=True), True),
("eku", x509.ExtendedKeyUsage(purposes), False),
("ski", x509.SubjectKeyIdentifier.from_public_key(key.public_key()), False),
("aki", x509.AuthorityKeyIdentifier.from_issuer_public_key(ca.key.public_key()), False),
]
for label, value, critical in extensions:
if value is not None and label not in omit:
builder = builder.add_extension(value, critical=critical)
return Identity(builder.sign(ca.key, hashes.SHA256()), key)
The extensions here are what make a certificate usable, so look at each one:
- SAN carries the names. A server needs the DNS name or IP address its clients connect to. A client certificate uses a DNS name as its identity, and in Step 3 the server will read that name to decide what the caller may do.
- EKU is
serverAuthfor a server andclientAuthfor a client, so a stolen client certificate cannot be used to impersonate a server. - KeyUsage allows
digitalSignature, the operation an ECDSA key performs during the handshake. - BasicConstraints with
ca=Falsesays this certificate cannot sign other certificates. - SKI and AKI identify this key and the issuing key. Strict validation, which Python 3.13 turned on by default, checks for them, and Step 5 shows exactly which ones it insists on.
- The lifetime is 30 days, a deliberately short default, because a certificate that expires on its own needs no revocation machinery to remove it.
Save this as step02_issue.py and run it. It reloads the CA from disk and issues three certificates: one for the server (valid for localhost and 127.0.0.1) and one each for two callers named billing and reporting.
# step02_issue.py
from cryptography import x509
from pki import issue, load, san_dns_names
ca = load("certs", "ca")
people = {
"server": issue(ca, "localhost", "server", dns=["localhost"], ips=["127.0.0.1"]),
"billing": issue(ca, "billing", "client", dns=["billing.lab.test"]),
"reporting": issue(ca, "reporting", "client", dns=["reporting.lab.test"]),
}
for stem, identity in people.items():
identity.save("certs", stem)
eku = identity.cert.extensions.get_extension_for_class(x509.ExtendedKeyUsage).value
print(f"{stem:<10} names={san_dns_names(identity.cert)} purpose={[oid._name for oid in eku]} "
f"serial={identity.cert.serial_number % 10**6:06d}...")
server names=['localhost'] purpose=['serverAuth'] serial=341760...
billing names=['billing.lab.test'] purpose=['clientAuth'] serial=050234...
reporting names=['reporting.lab.test'] purpose=['clientAuth'] serial=603903...
The serial numbers are random, so yours will differ. Now ask the OpenSSL command-line tool, which knows nothing about Python, whether it agrees that the certificates are valid for their intended purposes. These commands use the files in certs/.
openssl verify -CAfile certs/ca.pem -purpose sslserver certs/server.pem
openssl verify -CAfile certs/ca.pem -purpose sslclient certs/billing.pem
openssl verify -CAfile certs/ca.pem -purpose sslserver certs/billing.pem
certs/server.pem: OK
certs/billing.pem: OK
O=Lab PKI, CN=billing
error 26 at 0 depth lookup: unsuitable certificate purpose
error certs/billing.pem: verification failed
The first two commands print OK: the CA you built in Python vouches for those certificates, and OpenSSL agrees they are fit for the purposes named. The third command asks whether the billing certificate could act as a server, and OpenSSL refuses with error 26, unsuitable certificate purpose. That refusal is the extended key usage doing its job. To see the names and purposes OpenSSL reads from the server certificate, run one more command.
openssl x509 -in certs/server.pem -noout -subject -ext subjectAltName,extendedKeyUsage
subject=O=Lab PKI, CN=localhost
X509v3 Subject Alternative Name:
DNS:localhost, IP Address:127.0.0.1
X509v3 Extended Key Usage:
TLS Web Server Authentication
Step 3: Write the mutual-TLS server and client
Now the part that does the work. Create mtls.py in six pieces, appending each to the file. The first piece holds the imports, an access-control table, and two small helpers. TLS tells you who the caller is. It does not tell you what that caller may do, and that is a second decision, so the table makes it explicit: billing.lab.test may call /invoices, reporting.lab.test may call /reports, and both may call /whoami. The describe helper turns an exception into one short line, and caller_names reads the DNS names from the dictionary that SSLSocket.getpeercert() returns for a verified client certificate.
# mtls.py
"""mtls.py - a small mutual-TLS HTTP server and client built on the standard library."""
import contextlib
import http.client
import json
import socket
import ssl
import threading
import time
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
# Which verified caller may use which route. TLS proves who the caller is; this table decides what they may do.
ACL = {
"/whoami": {"billing.lab.test", "reporting.lab.test"},
"/invoices": {"billing.lab.test"},
"/reports": {"reporting.lab.test"},
}
def describe(exc):
"""One short line for an exception raised while talking TLS."""
if isinstance(exc, ssl.SSLCertVerificationError):
return f"{type(exc).__name__}: {exc.verify_message}"
if isinstance(exc, ssl.SSLError):
return f"{type(exc).__name__}: {exc.reason}"
return type(exc).__name__ # e.g. ConnectionResetError: the message differs per OS
def caller_names(peer_cert):
"""DNS names from the dict that SSLSocket.getpeercert() returns for a verified client certificate."""
return [value for kind, value in peer_cert.get("subjectAltName", ()) if kind == "DNS"]
The request handler comes next. It reads the caller’s name from the connection, then applies three rules. The route /status deliberately has no identity check: it stands in for the very common assumption that anything that reached the server must already have been vetted by mTLS. A request with no identity gets 401, an identity that is not on the route’s list gets 403, and everything else gets 200. The difference matters. A 401 means I do not know who you are, and a 403 means I know who you are and the answer is no.
# mtls.py (continued)
class Handler(BaseHTTPRequestHandler):
def do_GET(self):
names = caller_names(self.connection.getpeercert() or {})
caller = names[0] if names else None
if self.path == "/status": # no identity check: "protected by mTLS alone"
return self.reply(200, {"status": "ok"})
if caller is None:
return self.reply(401, {"error": "no client identity"})
if caller not in ACL.get(self.path, set()):
return self.reply(403, {"error": f"{caller} may not call {self.path}"})
self.reply(200, {"caller": caller, "path": self.path})
def reply(self, status, payload):
body = json.dumps(payload).encode()
self.send_response(status)
self.send_header("Content-Type", "application/json")
self.send_header("Content-Length", str(len(body)))
self.end_headers()
self.wfile.write(body)
def log_message(self, *args): # keep the demo output quiet
pass
The server is the standard library’s ThreadingHTTPServer with one change. A common pattern wraps the listening socket itself in TLS, and the accept() of such a socket performs each handshake inline, so the accept loop does that work itself. Here finish_request, which the library runs in a worker thread, performs the handshake with wrap_socket(request, server_side=True). A slow or hostile client then ties up only its own thread, and a failed handshake is recorded in failures together with its reason, which is exactly what you want in a log. ssl.SSLError is a subclass of OSError, so one except OSError catches it. The serve helper starts the server on a free port (port 0 lets the operating system choose) and stops it afterwards. wait_for_failures exists because the worker thread records a failure a moment after the client sees it.
# mtls.py (continued)
class MTLSServer(ThreadingHTTPServer):
"""HTTP server that runs the TLS handshake inside the worker thread and records handshake failures."""
daemon_threads = True
def __init__(self, context, port=0):
super().__init__(("127.0.0.1", port), Handler) # port 0: the OS picks a free port
self.context = context
self.failures = []
@property
def port(self):
return self.server_address[1]
def finish_request(self, request, client_address):
try:
tls = self.context.wrap_socket(request, server_side=True) # the handshake happens here
except OSError as exc: # ssl.SSLError is a subclass of OSError
self.failures.append(describe(exc))
return
try:
self.RequestHandlerClass(tls, client_address, self)
finally:
tls.close()
def wait_for_failures(self, count, timeout=3.0):
"""The worker thread records a failure a moment after the client sees it, so wait for it."""
deadline = time.monotonic() + timeout
while len(self.failures) < count and time.monotonic() < deadline:
time.sleep(0.01)
return list(self.failures)
@contextlib.contextmanager
def serve(context):
server = MTLSServer(context)
thread = threading.Thread(target=server.serve_forever, kwargs={"poll_interval": 0.05}, daemon=True)
thread.start()
try:
yield server
finally:
server.shutdown()
server.server_close()
Next come the two functions that build the TLS contexts, and these hold the security-critical lines. In server_context, create_default_context(ssl.Purpose.CLIENT_AUTH, cafile=...) gives sensible TLS settings and trusts the CA file you pass in. That argument matters. The documentation warns: “If all three are None, this function can choose to trust the system’s default CA certificates instead.” For client authentication you do not want that, because every public CA on the machine would then be able to vouch for a caller. load_cert_chain loads the server’s own certificate and key. And verify_mode = ssl.CERT_REQUIRED turns client authentication on. The documentation describes it this way: “In this mode, certificates are required from the other side of the socket connection; an SSLError will be raised if no certificate is provided, or if its validation fails.” The remaining keyword arguments (require_client_cert, crl_pem, check_revocation, strict) exist so that Steps 4 to 6 can change one thing at a time.
# mtls.py (continued)
def server_context(cert_pem, key_pem, ca_pem, *, require_client_cert=True, crl_pem=None,
check_revocation=False, strict=True):
# create_default_context() gives sane TLS settings and trusts only the CA file we pass in.
context = ssl.create_default_context(ssl.Purpose.CLIENT_AUTH, cafile=str(ca_pem))
context.load_cert_chain(str(cert_pem), str(key_pem))
if require_client_cert:
context.verify_mode = ssl.CERT_REQUIRED # without this line the server never asks for a client certificate
if not strict:
context.verify_flags &= ~ssl.VERIFY_X509_STRICT # an escape hatch, used in step 5 only to prove a point
if crl_pem:
context.load_verify_locations(cafile=str(crl_pem))
if check_revocation:
context.verify_flags |= ssl.VERIFY_CRL_CHECK_LEAF
return context
def client_context(ca_pem, identity_files=None, *, max_version=None, strict=True):
context = ssl.create_default_context(cafile=str(ca_pem)) # verifies the server and its hostname
if identity_files:
context.load_cert_chain(str(identity_files[0]), str(identity_files[1]))
if max_version:
context.maximum_version = max_version
if not strict:
context.verify_flags &= ~ssl.VERIFY_X509_STRICT
return context
The last two pieces are the client side. open_tls opens a TCP connection to 127.0.0.1 but tells TLS to check the certificate against a name you choose, and the small Connection class plugs it into http.client. There is a practical reason for that detour on Windows. When I connected to localhost, each request took about two seconds, against hundredths of a second for 127.0.0.1. The name resolved to the IPv6 address ::1 first, and my server listens on IPv4 only, so the first attempt had to fail before the second one succeeded. Connecting to the IPv4 address and naming localhost for the certificate check keeps the lab fast and still tests the name check.
# mtls.py (continued)
def open_tls(server, context, name="localhost"):
"""Open a TLS connection to the lab server. The TCP connection goes to 127.0.0.1, while the certificate is
checked against name (on Windows, connecting to "localhost" tries ::1 first and waits about 2 s for it to fail)."""
raw = socket.create_connection(("127.0.0.1", server.port), timeout=5)
try:
return context.wrap_socket(raw, server_hostname=name)
except BaseException:
raw.close()
raise
class Connection(http.client.HTTPSConnection):
def __init__(self, server, context, name):
super().__init__("127.0.0.1", server.port, timeout=5)
self.server, self.tls_context, self.name = server, context, name
def connect(self):
self.sock = open_tls(self.server, self.tls_context, self.name)
# mtls.py (continued)
def get(server, path, context, host="localhost"):
"""GET path over TLS. Returns (status, body) on success or (None, description) when TLS fails."""
connection = Connection(server, context, host)
try:
connection.request("GET", path)
response = connection.getresponse()
return response.status, json.loads(response.read())
except OSError as exc:
return None, describe(exc)
finally:
connection.close()
The get function returns the HTTP status and the JSON body when the request works, and None plus a one-line description when TLS fails. Save the following as step03_serve.py and run it. It starts the server and calls it as billing and as reporting.
# step03_serve.py
from mtls import client_context, get, serve, server_context
context = server_context("certs/server.pem", "certs/server.key", "certs/ca.pem")
billing = client_context("certs/ca.pem", ("certs/billing.pem", "certs/billing.key"))
reporting = client_context("certs/ca.pem", ("certs/reporting.pem", "certs/reporting.key"))
with serve(context) as server:
print("billing GET /whoami ->", *get(server, "/whoami", billing))
print("billing GET /invoices ->", *get(server, "/invoices", billing))
print("reporting GET /invoices ->", *get(server, "/invoices", reporting))
print("reporting GET /reports ->", *get(server, "/reports", reporting))
print("handshake failures recorded by the server:", server.failures)
billing GET /whoami -> 200 {'caller': 'billing.lab.test', 'path': '/whoami'}
billing GET /invoices -> 200 {'caller': 'billing.lab.test', 'path': '/invoices'}
reporting GET /invoices -> 403 {'error': 'reporting.lab.test may not call /invoices'}
reporting GET /reports -> 200 {'caller': 'reporting.lab.test', 'path': '/reports'}
handshake failures recorded by the server: []
Check that it worked. Four things happened here, and each is worth noticing. Both callers got through the TLS handshake, because both hold certificates signed by your CA. The billing caller was allowed to call /invoices. The reporting caller received a 403 for the same route, which proves the server read a verified identity from the certificate and applied the table. And the list of handshake failures is empty. You now have working mutual TLS. The rest of this tutorial is about the ways it stops working.
Step 4: Break the trust on purpose
Real deployments fail in a handful of repeating ways, and the quickest way to recognise them is to cause them. Each experiment below changes exactly one thing and shows what both sides saw. Save the following as step04_trust.py. It uses the certificates from Step 2, and it builds a second, unrelated CA to play an intruder.
# step04_trust.py
import collections
import ssl
from mtls import client_context, describe, get, open_tls, serve, server_context
from pki import issue, make_ca
CA = "certs/ca.pem"
strict = server_context("certs/server.pem", "certs/server.key", CA)
anonymous = client_context(CA) # trusts our CA but presents no certificate
def tally(server, context, tries=10):
"""What does the client see across several attempts? (the answer is not always the same)"""
return dict(collections.Counter(get(server, "/status", context)[1] for _ in range(tries)))
def stage_of_failure(server, context):
"""Say whether the error shows up during connect() or only when the connection is first used."""
try:
tls = open_tls(server, context)
except OSError:
return "connect() failed"
try:
tls.sendall(b"GET /status HTTP/1.0\r\n\r\n")
tls.recv(200)
except OSError as exc:
return f"connect() succeeded, first use failed ({describe(exc)})"
finally:
tls.close()
return "no error"
print("=== 1. no client certificate")
with serve(strict) as server:
print("client saw (10 tries):", tally(server, anonymous))
print("server recorded :", set(server.wait_for_failures(10)))
print("=== 2. TLS 1.3 versus TLS 1.2: when does the client find out?")
with serve(strict) as server:
print("TLS 1.3 client:", stage_of_failure(server, anonymous))
with serve(strict) as server:
old = client_context(CA, max_version=ssl.TLSVersion.TLSv1_2)
print("TLS 1.2 client:", stage_of_failure(server, old))
print("=== 3. a certificate from a different CA that claims the right name")
rogue_ca = make_ca("Rogue CA")
rogue_files = issue(rogue_ca, "billing", "client", dns=["billing.lab.test"]).save("certs/rogue", "billing")
intruder = client_context(CA, rogue_files)
with serve(strict) as server:
print("client saw (10 tries):", tally(server, intruder))
print("server recorded :", set(server.wait_for_failures(10)))
print("=== 4. the server forgot verify_mode")
lax = server_context("certs/server.pem", "certs/server.key", CA, require_client_cert=False)
print("verify_mode :", repr(lax.verify_mode))
with serve(lax) as server:
print("no cert /status :", *get(server, "/status", anonymous))
print("no cert /invoices :", *get(server, "/invoices", anonymous))
print("rogue /invoices :", *get(server, "/invoices", intruder))
Run it. The output has four sections, and the next four subsections explain them one at a time. One caution first: some lines come from the network stack and vary from run to run. The lines that begin with client saw show a count of how often each outcome occurred in ten tries, and your counts will differ from mine.
4.1 The caller presents no certificate
=== 1. no client certificate
client saw (10 tries): {'SSLError: TLSV13_ALERT_CERTIFICATE_REQUIRED': 4, 'ConnectionResetError': 6}
server recorded : {'SSLError: PEER_DID_NOT_RETURN_A_CERTIFICATE'}
The server’s record is the same every time: PEER_DID_NOT_RETURN_A_CERTIFICATE. The client’s view is not. In my run it saw either the TLS alert TLSV13_ALERT_CERTIFICATE_REQUIRED or a bare ConnectionResetError, and the mix changed between runs. The alert is defined in RFC 8446, the TLS 1.3 specification: “certificate_required: Sent by servers when a client certificate is desired but none was provided by the client.” My best explanation for the resets is that the server closes the socket while the client’s request is still on the wire, and the operating system reports that as a reset that can overtake the alert. I did not capture packets to prove it, so treat it as a theory. The practical lesson does not depend on it: when a client says only connection reset, the server’s own record of the handshake failure is the reliable place to look, which is why MTLSServer keeps one.
4.2 TLS 1.3 tells the client later than TLS 1.2 does
=== 2. TLS 1.3 versus TLS 1.2: when does the client find out?
TLS 1.3 client: connect() succeeded, first use failed (SSLError: TLSV13_ALERT_CERTIFICATE_REQUIRED)
TLS 1.2 client: connect() failed
This pair of lines is the most surprising result in the tutorial. With TLS 1.3 the client’s connect() succeeded and the failure only appeared when it first tried to use the connection. With TLS 1.2 the failure happened inside connect(). The error text in the parentheses is the alert or reset again, so it can vary, but the stage does not. RFC 8446 explains why. The client sends its certificate and its Finished message in its final flight: “Upon receiving the server’s messages, the client responds with its Authentication messages, namely Certificate and CertificateVerify (if requested), and Finished.” The next sentence in the RFC says: “At this point, the handshake is complete, and the client and server derive the keying material required by the record layer to exchange application-layer data protected through authenticated encryption.” From the client’s side, then, the handshake is over before the server has examined the client’s certificate. The server’s verdict arrives afterwards as an alert.
The consequence is easy to miss. Code that treats a successful connect() as proof that the server accepted it is wrong under TLS 1.3. Make a real request and check for errors on the first write or read, and test the rejection path, as you will do in Step 8.
4.3 The certificate comes from a different CA
=== 3. a certificate from a different CA that claims the right name
client saw (10 tries): {'ConnectionResetError': 8, 'SSLError: TLSV1_ALERT_UNKNOWN_CA': 2}
server recorded : {'SSLCertVerificationError: unable to get local issuer certificate'}
The intruder built its own CA, issued itself a certificate that claims the name billing.lab.test, and presented it. The server recorded unable to get local issuer certificate, which means the signature chain led to a CA that is not in its trust store. A name inside a certificate is only a claim. What makes it true is a signature from a CA you trust, and this is also why the server context in Step 3 trusts only your own CA file. Trust the system store for client authentication and any certificate from any public CA would pass this check.
4.4 The server forgets to ask for a certificate
=== 4. the server forgot verify_mode
verify_mode : <VerifyMode.CERT_NONE: 0>
no cert /status : 200 {'status': 'ok'}
no cert /invoices : 401 {'error': 'no client identity'}
rogue /invoices : 401 {'error': 'no client identity'}
This is the most dangerous failure in the list, because nothing fails. Here the server context was built the obvious way and the verify_mode line was left out, so it stayed at CERT_NONE, and per the documentation quoted in Step 0 no certificate is requested from the client. The result is that a caller with no certificate at all reached /status and received a 200. The two identity-checked routes still refused the caller, but only because the application code happened to check for an identity, and without a certificate request getpeercert() has nothing to return. Any route that relied on mTLS alone was wide open.
Two habits prevent this. Assert the setting where you build the context, for example assert context.verify_mode == ssl.CERT_REQUIRED, and keep a test that connects without a certificate and expects a refusal. The test suite in Step 8 does both kinds of checking.
Step 5: Break the certificates on purpose
The trust chain was fine in Step 4. Now keep the chain intact and damage one property of a certificate at a time. Save the following as step05_certs.py. The attempt function runs one handshake and reports the verdict of the side that rejected it, because that side knows the real reason.
# step05_certs.py
import tempfile
from datetime import datetime, timedelta, timezone
from cryptography.x509.oid import ExtendedKeyUsageOID
from mtls import client_context, get, serve, server_context
from pki import issue, load, make_ca, san_dns_names
ca = load("certs", "ca")
now = datetime.now(timezone.utc)
good_server = issue(ca, "localhost", "server", dns=["localhost"], ips=["127.0.0.1"])
good_client = issue(ca, "billing", "client", dns=["billing.lab.test"])
def attempt(label, ca, server_identity, client_identity, *, host="localhost", relax_strict=False):
"""Run one handshake with the given certificates and print the verdict of the side that rejected it."""
with tempfile.TemporaryDirectory() as folder:
ca_pem, _ = ca.save(folder, "ca")
cert_pem, key_pem = server_identity.save(folder, "server")
client_files = client_identity.save(folder, "client")
server_ctx = server_context(cert_pem, key_pem, ca_pem, strict=not relax_strict)
with serve(server_ctx) as server:
context = client_context(ca_pem, client_files, strict=not relax_strict)
status, detail = get(server, "/whoami", context, host=host)
if status is not None:
print(f"{label:<52} -> {status} accepted")
return
# Both sides log something, but only the side that checked the certificate knows why.
verdicts = [("client", detail)] + [("server", f) for f in server.wait_for_failures(1)]
side, why = next(v for v in verdicts if v[1].startswith("SSLCertVerificationError"))
print(f"{label:<52} -> rejected by the {side}: {why.split(': ', 1)[1]}")
print("=== 1. the server name in the certificate")
cn_only = issue(ca, "localhost", "server", omit={"san"})
attempt("no subjectAltName, CN=localhost", ca, cn_only, good_client)
print(" lint: SAN names of cn_only:", san_dns_names(cn_only.cert), "| of good_server:", san_dns_names(good_server.cert))
attempt("SAN lists another name", ca, issue(ca, "localhost", "server", dns=["other.lab.test"]), good_client)
attempt("DNS SAN only, host 127.0.0.1", ca, issue(ca, "localhost", "server", dns=["localhost"]), good_client, host="127.0.0.1")
attempt("DNS and IP SAN, host 127.0.0.1", ca, good_server, good_client, host="127.0.0.1")
print("=== 2. the wrong extended key usage")
attempt("server cert is clientAuth only", ca,
issue(ca, "localhost", "server", dns=["localhost"], eku=[ExtendedKeyUsageOID.CLIENT_AUTH]), good_client)
attempt("client cert is serverAuth only", ca, good_server,
issue(ca, "billing", "client", dns=["billing.lab.test"], eku=[ExtendedKeyUsageOID.SERVER_AUTH]))
print("=== 3. expired and not yet valid")
attempt("server cert expired 5 days ago", ca,
issue(ca, "localhost", "server", dns=["localhost"], not_before=now - timedelta(days=10), days=5), good_client)
attempt("server cert valid from tomorrow", ca,
issue(ca, "localhost", "server", dns=["localhost"], not_before=now + timedelta(days=1)), good_client)
attempt("client cert expired 5 days ago", ca, good_server,
issue(ca, "billing", "client", dns=["billing.lab.test"], not_before=now - timedelta(days=10), days=5))
print("=== 4. Python 3.13 strict mode: extensions that RFC 5280 requires")
ca_no_ski = make_ca("CA without SKI", omit={"ski"})
ca_no_ku = make_ca("CA without keyUsage", omit={"key_usage"})
broken = {
"server cert without AKI": (ca, issue(ca, "localhost", "server", dns=["localhost"], omit={"aki"}), good_client),
"CA without SKI": (ca_no_ski, issue(ca_no_ski, "localhost", "server", dns=["localhost"]),
issue(ca_no_ski, "billing", "client", dns=["billing.lab.test"])),
"CA without keyUsage": (ca_no_ku, issue(ca_no_ku, "localhost", "server", dns=["localhost"]),
issue(ca_no_ku, "billing", "client", dns=["billing.lab.test"])),
}
for label, args in broken.items():
attempt(label, *args)
attempt(label + ", strict flag cleared on both ends", *args, relax_strict=True)
5.1 Names: what the certificate says it is for
=== 1. the server name in the certificate
no subjectAltName, CN=localhost -> 200 accepted
lint: SAN names of cn_only: [] | of good_server: ['localhost']
SAN lists another name -> rejected by the client: Hostname mismatch, certificate is not valid for 'localhost'.
DNS SAN only, host 127.0.0.1 -> rejected by the client: IP address mismatch, certificate is not valid for '127.0.0.1'.
DNS and IP SAN, host 127.0.0.1 -> 200 accepted
Start with the first line, which surprises people. A server certificate with no SAN at all, only CN=localhost in the subject, was accepted. This is lenient behaviour, not a feature to rely on. The OpenSSL documentation for its host-name check says: “the default is to use the subject DN when no corresponding subject alternative names are present.” Python hands the name check to OpenSSL (its documentation says: “Hostname or IP address is matched by OpenSSL during handshake.”), and OpenSSL’s own default is to fall back to the subject. Stricter software does not. The Go 1.15 release notes state: “The deprecated, legacy behavior of treating the CommonName field on X.509 certificates as a host name when no Subject Alternative Names are present is now disabled by default.” And RFC 9525, the current specification for service identity in TLS, says: “The Common Name RDN MUST NOT be used to identify a service because it is not strongly typed (it is essentially free-form text) and therefore suffers from ambiguities in interpretation.” A certificate that passes in your Python test can therefore fail in another client. The lint line shows a one-line defence: san_dns_names returns an empty list for the CN-only certificate, so a check in your issuing code can refuse to produce one.
The second and third lines show the failures you will actually meet. When the SAN lists a different name, the client rejects the certificate with Hostname mismatch. When you connect to an IP address, the certificate needs an IP address entry in its SAN: a DNS entry is not enough, and the message becomes IP address mismatch. The fourth line shows the fix, a SAN with both a DNS name and an IP address.
5.2 Purpose: what the key is allowed to do
=== 2. the wrong extended key usage
server cert is clientAuth only -> rejected by the client: unsuitable certificate purpose
client cert is serverAuth only -> rejected by the server: unsuitable certificate purpose
A certificate whose extended key usage names only clientAuth cannot be used by a server, and one that names only serverAuth cannot be used by a client, in either direction. The two verdicts come from different sides: the client rejected the first and the server rejected the second, which is why attempt reports whichever side raised the verification error. RFC 5280 is plain about it: “If the extension is present, then the certificate MUST only be used for one of the purposes indicated.” You saw the same refusal from the OpenSSL command line in Step 2.
5.3 Time: when the certificate is valid
=== 3. expired and not yet valid
server cert expired 5 days ago -> rejected by the client: certificate has expired
server cert valid from tomorrow -> rejected by the client: certificate is not yet valid
client cert expired 5 days ago -> rejected by the server: certificate has expired
Expired and not-yet-valid certificates are rejected, and the reason is in the verdict. The not-yet-valid case is the one that comes from clock differences, and it is why make_ca and issue start every certificate five minutes in the past. When the client certificate has expired, the server is the side that knows. Notice what this implies for operations: a 30-day certificate that nobody renews becomes an outage on day 31, so automate renewal before you rely on short lifetimes.
5.4 Strictness: the Python 3.13 change that breaks hand-made CAs
=== 4. Python 3.13 strict mode: extensions that RFC 5280 requires
server cert without AKI -> rejected by the client: Missing Authority Key Identifier
server cert without AKI, strict flag cleared on both ends -> 200 accepted
CA without SKI -> rejected by the client: Missing Subject Key Identifier
CA without SKI, strict flag cleared on both ends -> 200 accepted
CA without keyUsage -> rejected by the client: Path length given without key usage keyCertSign
CA without keyUsage, strict flag cleared on both ends -> 200 accepted
These six lines are the reason many hand-made CAs started failing after an upgrade. The Python 3.13 release notes say: “The create_default_context() API now includes VERIFY_X509_PARTIAL_CHAIN and VERIFY_X509_STRICT in its default flags.” and warn that “VERIFY_X509_STRICT may reject pre-RFC 5280 or malformed certificates that the underlying OpenSSL implementation might otherwise accept.” The ssl documentation adds that these defaults “make the underlying OpenSSL implementation behave more like a conforming implementation of RFC 5280, in exchange for a small amount of incompatibility with older X.509 certificates.” Because the change is in the defaults, a CA that worked on an older Python can start failing on 3.13 without any change to your code. I only tested on 3.13.14, so the older behaviour is what the documentation implies, not something I measured.
Each of the three messages points to a requirement in RFC 5280, and your issuing code should satisfy all of them:
- Missing Authority Key Identifier. RFC 5280 says: “The keyIdentifier field of the authorityKeyIdentifier extension MUST be included in all certificates generated by conforming CAs to facilitate certification path construction.” The one exception is a self-signed root.
- Missing Subject Key Identifier. For CA certificates, the RFC says the extension “MUST appear in all conforming CA certificates, that is, all certificates including the basic constraints extension (Section 4.2.1.9) where the value of cA is TRUE.”
- Path length given without key usage keyCertSign. This CA has a path length of zero but no key usage extension. The RFC says: “Conforming CAs MUST include this extension in certificates that contain public keys that are used to validate digital signatures on other public key certificates or CRLs.” OpenSSL’s verification options page lists the same requirement for its strict mode: “CA certificates must explicitly include the keyUsage extension. If a pathlenConstraint is given the key usage keyCertSign must be allowed.”
The line after each failure shows the same certificates accepted once the strict flag is cleared on both ends. The Python documentation shows how to do that and adds: “While disabling this is not recommended, you can do so using:” followed by ctx.verify_flags &= ~ssl.VERIFY_X509_STRICT. Treat that line as a diagnostic, not a fix. It proves that strict mode is what rejected your certificate, and the real fix is to add the missing extension, which is exactly what make_ca and issue already do by default.
Step 6: Revocation is opt-in
Expiry removes a certificate eventually. Revocation removes it now, for example when a caller’s private key leaks. The CA does this by signing a list of serial numbers it no longer vouches for. Append this function to pki.py.
# pki.py (continued)
def make_crl(ca, revoked=(), days=7):
"""Build a certificate revocation list signed by the CA that lists the given certificates."""
now = datetime.datetime.now(UTC)
builder = (
x509.CertificateRevocationListBuilder()
.issuer_name(ca.cert.subject)
.last_update(now - datetime.timedelta(minutes=5))
.next_update(now + datetime.timedelta(days=days))
.add_extension(x509.AuthorityKeyIdentifier.from_issuer_public_key(ca.key.public_key()), critical=False)
.add_extension(x509.CRLNumber(1), critical=False)
)
for cert in revoked:
entry = (
x509.RevokedCertificateBuilder()
.serial_number(cert.serial_number)
.revocation_date(now - datetime.timedelta(minutes=1))
.build()
)
builder = builder.add_revoked_certificate(entry)
return builder.sign(ca.key, hashes.SHA256())
Save the next script as step06_revocation.py. It revokes the billing certificate and then runs three trials against three differently configured servers.
# step06_revocation.py
from cryptography.hazmat.primitives import serialization
from mtls import client_context, get, serve, server_context
from pki import load, make_crl
ca = load("certs", "ca")
billing_identity = load("certs", "billing")
# The CA signs a list that names the certificates it no longer vouches for.
crl = make_crl(ca, revoked=[billing_identity.cert])
with open("certs/ca.crl.pem", "wb") as handle:
handle.write(crl.public_bytes(serialization.Encoding.PEM))
print("billing's serial number is on the list:", any(r.serial_number == billing_identity.cert.serial_number for r in crl))
billing = client_context("certs/ca.pem", ("certs/billing.pem", "certs/billing.key"))
reporting = client_context("certs/ca.pem", ("certs/reporting.pem", "certs/reporting.key"))
def trial(label, **options):
context = server_context("certs/server.pem", "certs/server.key", "certs/ca.pem", **options)
with serve(context) as server:
print(label)
statuses = {}
for who, client in [("billing (revoked)", billing), ("reporting", reporting)]:
statuses[who], _ = get(server, "/whoami", client)
print(f" {who:<18}:", statuses[who] or "rejected")
rejected = sum(1 for status in statuses.values() if status is None)
print(" server recorded :", sorted(set(server.wait_for_failures(rejected))))
trial("1. default server context (no revocation checking)")
trial("2. CRL loaded and VERIFY_CRL_CHECK_LEAF set", crl_pem="certs/ca.crl.pem", check_revocation=True)
trial("3. flag set but no CRL loaded", check_revocation=True)
billing's serial number is on the list: True
1. default server context (no revocation checking)
billing (revoked) : 200
reporting : 200
server recorded : []
2. CRL loaded and VERIFY_CRL_CHECK_LEAF set
billing (revoked) : rejected
reporting : 200
server recorded : ['SSLCertVerificationError: certificate revoked']
3. flag set but no CRL loaded
billing (revoked) : rejected
reporting : rejected
server recorded : ['SSLCertVerificationError: unable to get certificate CRL']
The first trial is the uncomfortable one. The certificate is revoked and the server still accepted it. The Python documentation states the default plainly: “By default OpenSSL does neither require nor verify certificate revocation lists (CRLs).” Merely having a CRL on disk changes nothing. In the second trial, loading the list and setting VERIFY_CRL_CHECK_LEAF made the server reject billing with certificate revoked while reporting still passed. The documentation describes that mode as one where “only the peer cert is checked but none of the intermediate CA certificates”.
The third trial shows that the check fails closed. With the flag set and no list loaded, every caller was refused with unable to get certificate CRL, which matches the documentation: “If no proper CRL has been loaded with SSLContext.load_verify_locations, validation will fail.” That is the safe behaviour, but it means an expired or missing list takes your callers down. A context reads the file once, when you load it, so a long-running server needs a way to build a fresh context whenever the list changes. A common alternative is to use short-lived certificates instead of lists, because a certificate that expires in a day or a week needs far less revocation machinery.
A field guide to the errors
Here is everything you reproduced, in one table. The wording comes from OpenSSL 3.0.21, so another build may phrase a message slightly differently. Match on the idea, not on the exact string.
| What you see | Who reports it | Usual cause | Fix |
|---|---|---|---|
PEER_DID_NOT_RETURN_A_CERTIFICATE |
Server | The caller presented no certificate | Call load_cert_chain on the client context |
TLSV13_ALERT_CERTIFICATE_REQUIRED or a bare reset |
Client | The server rejected the caller | Read the server’s own failure record |
unable to get local issuer certificate |
Either side | The chain does not reach a CA in the trust store | Check the CA file and who signed the certificate |
Hostname mismatch or IP address mismatch |
Client | The name you connected to is not in the SAN | Add the DNS name or IP address to the SAN |
unsuitable certificate purpose |
Either side | The EKU does not allow this use | Issue with serverAuth or clientAuth |
certificate has expired or certificate is not yet valid |
Either side | Lifetime or clock problem | Renew; check clocks; backdate slightly |
Missing Authority Key Identifier, Missing Subject Key Identifier, Path length given without key usage keyCertSign |
Either side | Strict validation caught a missing extension | Fix the issuing code, not the flag |
certificate revoked |
The side that checks the list | The serial number is on the CRL | Issue a new certificate |
unable to get certificate CRL |
The side that checks the list | Revocation checking is on but no list is loaded | Load a current CRL |
One more pattern is worth remembering. When the client rejects the server’s certificate, the server’s own record only shows a generic alert. In my runs it showed SSLV3_ALERT_BAD_CERTIFICATE, SSLV3_ALERT_CERTIFICATE_EXPIRED and SSLV3_ALERT_CERTIFICATE_UNKNOWN for failures whose real reasons were a hostname, an expiry, and a missing extension. The reason lives on the side that checked, so when a server log says only bad certificate, go and read the client’s error.
Step 7: Check the server with a client that is not Python
Everything so far used Python on both ends, which can hide mistakes that are shared by both sides. The OpenSSL command-line tool is an independent implementation, and its s_client command makes a good second opinion. First save this small launcher as run_server.py. It serves the same server on a fixed port.
# run_server.py
"""run_server.py - serve the mutual-TLS demo on https://localhost:8443 until you press Ctrl+C."""
from mtls import MTLSServer, server_context
context = server_context("certs/server.pem", "certs/server.key", "certs/ca.pem")
server = MTLSServer(context, port=8443)
print("listening on https://localhost:8443 (press Ctrl+C to stop)", flush=True)
try:
server.serve_forever()
except KeyboardInterrupt:
pass
finally:
server.server_close()
Start it in one terminal with python run_server.py. In a second terminal, run the command below. It connects with the billing certificate, trusts only your CA, and checks the name localhost. After the handshake s_client waits for input, so press Ctrl+C to leave it.
openssl s_client -connect 127.0.0.1:8443 -servername localhost -CAfile certs/ca.pem -verify_hostname localhost -brief -cert certs/billing.pem -key certs/billing.key
---- s_client with the billing certificate (exit 0)
Connecting to 127.0.0.1
CONNECTION ESTABLISHED
Protocol version: TLSv1.3
Ciphersuite: TLS_AES_256_GCM_SHA384
Peer certificate: O=Lab PKI, CN=localhost
Hash used: SHA256
Signature type: ecdsa_secp256r1_sha256
Verification: OK
Verified peername: localhost
Peer Temp Key: X25519, 253 bits
DONE
The lines Verification: OK and Verified peername: localhost describe OpenSSL’s check of the server’s certificate. The client’s own certificate was accepted too, because the connection stayed up and ended with DONE (I trimmed one long line that lists the signature algorithms the client offered). Now run the same command twice more, once without the -cert and -key options and once with the certificate from the intruder’s CA in certs/rogue/. Because the outcome varies, I ran each of these six times and counted the lines that mention an alert or a closed connection.
---- s_client, no certificate, 6 attempts
6 of 6: CONNECTION CLOSED BY SERVER
---- s_client, certificate from the rogue CA, 6 attempts
3 of 6: CONNECTION CLOSED BY SERVER
3 of 6: error:0A000418:SSL routines:ssl3_read_bytes:tlsv1 alert unknown ca:../openssl-3.5.7/ssl/record/rec_layer_s3.c:918:SSL alert number 48
Both outcomes from Step 4 appear again. Against the intruder’s certificate, three of six attempts printed the alert and three printed CONNECTION CLOSED BY SERVER, which is the reset behaviour from Step 4.1. Without a certificate, all six attempts in this capture printed the closed-connection line, although an earlier run of the same command printed tlsv13 alert certificate required with SSL alert number 116. The alert numbers match the TLS 1.3 specification, which lists unknown_ca(48) and certificate_required(116). If you also want to try curl, know that on Windows it may not accept PEM files. My Windows build of curl uses the Schannel library and failed like this:
---- curl on Windows (exit 58): curl: (58) schannel: Failed to import cert file certs/billing.pem, last error is 0x80092002
curl 8.21.0 (Windows) libcurl/8.21.0 Schannel zlib/1.3.2 WinIDN WinLDAP
I did not test curl on a system with an OpenSSL-based build, so I make no claim about it there.
Step 8: Lock the behaviour in with tests
You have now seen more than a dozen outcomes, and a security setting that is not tested tends to drift. The final file turns every experiment into a test. Save it as test_mtls.py. The handshake helper builds a throwaway server and client from whatever identities you hand it and returns the status, the body or error, and the server’s recorded failures. The tests then assert on the side that actually knows the reason, which keeps them stable even though the other side’s alert-or-reset behaviour varies.
# test_mtls.py
"""test_mtls.py - the failures from steps 4 to 6, written as tests."""
import ssl
import tempfile
from datetime import datetime, timedelta, timezone
from types import SimpleNamespace
import pytest
from cryptography.hazmat.primitives import serialization
from cryptography.x509.oid import ExtendedKeyUsageOID
from mtls import client_context, get, open_tls, serve, server_context
from pki import issue, make_ca, make_crl, san_dns_names
NOW = datetime.now(timezone.utc)
DEFAULT = object()
@pytest.fixture(scope="module")
def lab():
ca = make_ca()
return SimpleNamespace(
ca=ca,
server=issue(ca, "localhost", "server", dns=["localhost"], ips=["127.0.0.1"]),
billing=issue(ca, "billing", "client", dns=["billing.lab.test"]),
reporting=issue(ca, "reporting", "client", dns=["reporting.lab.test"]),
)
def handshake(lab, *, server=None, client=DEFAULT, ca=None, host="localhost", path="/whoami",
strict=True, **server_options):
"""Serve with one identity, call with another. Returns (status, body or error text, server failures)."""
ca = ca or lab.ca
server = server or lab.server
client = lab.billing if client is DEFAULT else client
with tempfile.TemporaryDirectory() as folder:
ca_pem, _ = ca.save(folder, "ca")
cert_pem, key_pem = server.save(folder, "server")
client_files = client.save(folder, "client") if client else None
context = server_context(cert_pem, key_pem, ca_pem, strict=strict, **server_options)
with serve(context) as running:
status, detail = get(running, path, client_context(ca_pem, client_files, strict=strict), host=host)
failures = running.wait_for_failures(1) if status is None else []
return status, detail, failures
def test_billing_may_call_invoices(lab):
status, body, _ = handshake(lab, path="/invoices")
assert (status, body["caller"]) == (200, "billing.lab.test")
def test_a_valid_identity_without_permission_gets_403(lab):
status, body, _ = handshake(lab, client=lab.reporting, path="/invoices")
assert status == 403 and "may not call" in body["error"]
def test_no_client_certificate_is_rejected(lab):
status, _, failures = handshake(lab, client=None, path="/status")
assert status is None
assert "PEER_DID_NOT_RETURN_A_CERTIFICATE" in failures[0]
def test_certificate_from_another_ca_is_rejected(lab):
rogue = make_ca("Rogue CA")
intruder = issue(rogue, "billing", "client", dns=["billing.lab.test"])
status, _, failures = handshake(lab, client=intruder, path="/invoices")
assert status is None
assert "unable to get local issuer certificate" in failures[0]
def test_forgetting_verify_mode_lets_anonymous_callers_reach_unchecked_routes(lab):
assert handshake(lab, client=None, path="/status", require_client_cert=False)[0] == 200
assert handshake(lab, client=None, path="/invoices", require_client_cert=False)[0] == 401
def test_tls13_client_connects_before_the_server_rejects_it(lab):
with tempfile.TemporaryDirectory() as folder:
ca_pem, _ = lab.ca.save(folder, "ca")
cert_pem, key_pem = lab.server.save(folder, "server")
with serve(server_context(cert_pem, key_pem, ca_pem)) as server:
tls = open_tls(server, client_context(ca_pem))
assert tls.version() == "TLSv1.3" # the handshake "worked" from the client's point of view
with pytest.raises(OSError): # the verdict arrives on the first write or read
tls.sendall(b"GET /status HTTP/1.0\r\n\r\n")
tls.recv(200)
tls.close()
old = client_context(ca_pem, max_version=ssl.TLSVersion.TLSv1_2)
with pytest.raises(OSError): # TLS 1.2 fails inside the handshake itself
open_tls(server, old)
def test_python_accepts_a_server_certificate_with_only_a_common_name(lab):
cn_only = issue(lab.ca, "localhost", "server", omit={"san"})
assert san_dns_names(cn_only.cert) == []
assert handshake(lab, server=cn_only)[0] == 200 # lenient on purpose; other clients are not
def test_hostname_must_match_a_san(lab):
other = issue(lab.ca, "localhost", "server", dns=["other.lab.test"])
status, detail, _ = handshake(lab, server=other)
assert status is None and "Hostname mismatch" in detail
def test_an_ip_address_needs_an_ip_san(lab):
dns_only = issue(lab.ca, "localhost", "server", dns=["localhost"])
status, detail, _ = handshake(lab, server=dns_only, host="127.0.0.1")
assert status is None and "IP address mismatch" in detail
assert handshake(lab, host="127.0.0.1")[0] == 200
def test_server_certificate_must_allow_server_auth(lab):
wrong = issue(lab.ca, "localhost", "server", dns=["localhost"], eku=[ExtendedKeyUsageOID.CLIENT_AUTH])
status, detail, _ = handshake(lab, server=wrong)
assert status is None and "unsuitable certificate purpose" in detail
def test_client_certificate_must_allow_client_auth(lab):
wrong = issue(lab.ca, "billing", "client", dns=["billing.lab.test"], eku=[ExtendedKeyUsageOID.SERVER_AUTH])
status, _, failures = handshake(lab, client=wrong)
assert status is None and "unsuitable certificate purpose" in failures[0]
def test_expired_and_not_yet_valid_certificates_are_rejected(lab):
expired = issue(lab.ca, "localhost", "server", dns=["localhost"], not_before=NOW - timedelta(days=10), days=5)
assert "certificate has expired" in handshake(lab, server=expired)[1]
future = issue(lab.ca, "localhost", "server", dns=["localhost"], not_before=NOW + timedelta(days=1))
assert "certificate is not yet valid" in handshake(lab, server=future)[1]
def test_strict_mode_wants_an_authority_key_identifier(lab):
leaf = issue(lab.ca, "localhost", "server", dns=["localhost"], omit={"aki"})
assert "Missing Authority Key Identifier" in handshake(lab, server=leaf)[1]
assert handshake(lab, server=leaf, strict=False)[0] == 200
def test_strict_mode_wants_a_subject_key_identifier_on_the_ca():
ca = make_ca("CA without SKI", omit={"ski"})
leaves = dict(server=issue(ca, "localhost", "server", dns=["localhost"]),
client=issue(ca, "billing", "client", dns=["billing.lab.test"]))
assert "Missing Subject Key Identifier" in handshake(None, ca=ca, **leaves)[1]
assert handshake(None, ca=ca, strict=False, **leaves)[0] == 200
def test_revocation_is_opt_in(lab, tmp_path):
crl_pem = tmp_path / "ca.crl.pem"
crl_pem.write_bytes(make_crl(lab.ca, revoked=[lab.billing.cert]).public_bytes(serialization.Encoding.PEM))
assert handshake(lab, crl_pem=crl_pem)[0] == 200 # the list is loaded but nobody asked for it to be checked
status, _, failures = handshake(lab, crl_pem=crl_pem, check_revocation=True)
assert status is None and "certificate revoked" in failures[0]
assert handshake(lab, client=lab.reporting, crl_pem=crl_pem, check_revocation=True)[0] == 200
def test_checking_revocation_without_a_crl_fails_closed(lab):
status, _, failures = handshake(lab, check_revocation=True)
assert status is None and "unable to get certificate CRL" in failures[0]
python -m pytest -q
................ [100%]
16 passed in 2.28s
All 16 pass in a couple of seconds. A few of the tests are worth a second look. test_forgetting_verify_mode_lets_anonymous_callers_reach_unchecked_routes documents the dangerous case from Step 4.4 so that a future change that fixes or reintroduces it is noticed. test_tls13_client_connects_before_the_server_rejects_it encodes the Step 4.2 surprise. test_python_accepts_a_server_certificate_with_only_a_common_name records the lenient behaviour from Step 5.1, so you will find out if an upgrade changes it. And the revocation tests pin down both the opt-in default and the fail-closed behaviour.
Verify the whole thing end to end
To prove that you have a working setup, and not just a folder of files that happened to work once, start clean and run everything in order.
python step00_versions.py
python step01_ca.py
python step02_issue.py
python step03_serve.py
python step04_trust.py
python step05_certs.py
python step06_revocation.py
python -m pytest -q
Delete the certs folder first if it exists. Every script should exit without a traceback. Step 3 should show a 200 for billing on /invoices and a 403 for reporting, Step 4 should show the server recording PEER_DID_NOT_RETURN_A_CERTIFICATE and unable to get local issuer certificate, and the last command should end with 16 passed. The lines that vary are the dates, the serial numbers, and the alert-versus-reset counts. If an error message differs in wording, check which OpenSSL your Python reports in Step 0.
Mistakes to avoid
- Forgetting
verify_mode. A server built fromcreate_default_context(Purpose.CLIENT_AUTH)does not ask for client certificates until you setCERT_REQUIRED. Assert it and test it. - Trusting more CAs than you need. Pass your own CA file as the trust store for client authentication, so that no public CA can vouch for a caller.
- Reading names from the wrong place. Take the caller’s identity from the SAN of a verified certificate, never from a header or a field the client controls, and apply an access-control table after the handshake.
- Relying on lenient name matching. Always put names in the SAN, including an IP address entry when clients connect by address.
- Treating a successful
connect()as success under TLS 1.3. The server’s verdict can arrive on the first write or read. - Disabling strict mode instead of fixing the certificate. The flag is a diagnostic, and the missing extension is the problem.
- Assuming revocation works. It is off by default, it fails closed once enabled, and it needs a list that is kept current.
- Leaving keys unprotected. The lab writes them in plain text. Real CA keys belong offline or in a hardware module, and server keys need tight file permissions.
Where to go next
You can extend this lab in a few useful directions. First, set the root’s path length to 1 and add an intermediate CA that does the day-to-day signing, so that the root key can stay offline. Second, automate issuance and renewal, and shorten the lifetimes until expiry does most of the revocation work for you. Third, replace the hand-written access table with whatever identity-to-role mapping your platform uses.
If you want to see these ideas at platform scale, our article on Kubernetes pod certificates shows how a cluster can hand every workload a certificate and a trust bundle instead of a bearer token. If you are curious how certificates may change when signatures get larger, the tutorial on building a Merkle Tree certificate issuer measures post-quantum certificate sizes, and the tutorial on a hybrid post-quantum key exchange covers the other half of a TLS handshake. For the client side of a managed service, see how to connect to managed Redis over TLS.
You started with a server that trusted anyone who could reach it. You now have a CA of your own, a server that accepts only callers whose certificates it signed, an access table that turns identity into permission, and a field guide to every error that stands between you and a working deployment.








No Comment! Be the first one.