45. TLS and mutual TLS with the ssl module
45.1 A verifying client
import socket
import ssl
ctx = ssl.create_default_context() # verifies certificates and hostnames; TLS 1.2+
ctx.minimum_version = ssl.TLSVersion.TLSv1_2
with socket.create_connection(("www.python.org", 443), timeout=5) as raw:
with ctx.wrap_socket(raw, server_hostname="www.python.org") as tls: # SNI + hostname check
print(tls.version(), tls.cipher()[0])
cert = tls.getpeercert()
print(dict(x[0] for x in cert["subject"])["commonName"], "expires", cert["notAfter"])
tls.sendall(b"HEAD / HTTP/1.1\r\nHost: www.python.org\r\nConnection: close\r\n\r\n")
print(tls.recv(200).split(b"\r\n")[0])
ssl.create_default_context() is the only correct starting point. It
loads the system's trusted CA certificates, requires a valid certificate
chain, and checks that the certificate matches server_hostname. Each of
those steps is explained in the
Security guide Ch 29.
The ways to break it are all explicit: ctx.check_hostname = False,
ctx.verify_mode = ssl.CERT_NONE, or ssl._create_unverified_context().
Search your codebase for them.
On macOS, a python.org or uv-managed Python may not see the system
keychain; requests and httpx ship the certifi CA bundle for that
reason. If you get CERTIFICATE_VERIFY_FAILED for a public site, the
cause is a missing CA bundle, not a bad certificate; and the fix is never
verify=False. In corporate networks with TLS inspection, add the
corporate root CA to the trust store (SSL_CERT_FILE, or the
truststore package to use the OS store).
45.2 A TLS server with your own CA, and mutual TLS
Create a CA, a server certificate, and a client certificate, as in the
Security guide's own-CA project,
here with the cryptography package so it stays in Python:
# labs/mkcerts.py: a CA, a server cert for localhost, and a client cert (uv add cryptography)
import datetime as dt
import ipaddress
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
def key() -> ec.EllipticCurvePrivateKey:
return ec.generate_private_key(ec.SECP256R1())
def name(cn: str) -> x509.Name:
return x509.Name([x509.NameAttribute(NameOID.COMMON_NAME, cn)])
def save(path: str, k, cert: x509.Certificate) -> None:
Path(f"{path}.key").write_bytes(k.private_bytes(serialization.Encoding.PEM,
serialization.PrivateFormat.PKCS8, serialization.NoEncryption()))
Path(f"{path}.crt").write_bytes(cert.public_bytes(serialization.Encoding.PEM))
now = dt.datetime.now(dt.timezone.utc)
ca_key = key()
ca = (x509.CertificateBuilder().subject_name(name("Lab CA")).issuer_name(name("Lab CA"))
.public_key(ca_key.public_key()).serial_number(x509.random_serial_number())
.not_valid_before(now).not_valid_after(now + dt.timedelta(days=30))
.add_extension(x509.BasicConstraints(ca=True, path_length=0), critical=True)
.add_extension(x509.KeyUsage(digital_signature=True, key_cert_sign=True, crl_sign=True,
content_commitment=False, key_encipherment=False, data_encipherment=False,
key_agreement=False, encipher_only=False, decipher_only=False), critical=True)
.sign(ca_key, hashes.SHA256()))
save("ca", ca_key, ca)
def leaf(cn: str, usage, sans=None):
k = key()
b = (x509.CertificateBuilder().subject_name(name(cn)).issuer_name(ca.subject)
.public_key(k.public_key()).serial_number(x509.random_serial_number())
.not_valid_before(now).not_valid_after(now + dt.timedelta(days=7))
.add_extension(x509.ExtendedKeyUsage([usage]), critical=False)
.add_extension(x509.BasicConstraints(ca=False, path_length=None), critical=True))
if sans:
b = b.add_extension(x509.SubjectAlternativeName(sans), critical=False)
return k, b.sign(ca_key, hashes.SHA256())
save("server", *leaf("localhost", ExtendedKeyUsageOID.SERVER_AUTH,
[x509.DNSName("localhost"), x509.IPAddress(ipaddress.ip_address("127.0.0.1"))]))
save("client", *leaf("svc-orders", ExtendedKeyUsageOID.CLIENT_AUTH))
print("wrote ca, server, client .crt/.key")
# labs/mtls_server.py: require a client certificate signed by our CA
import socket
import ssl
ctx = ssl.SSLContext(ssl.PROTOCOL_TLS_SERVER)
ctx.minimum_version = ssl.TLSVersion.TLSv1_3
ctx.load_cert_chain("server.crt", "server.key")
ctx.load_verify_locations("ca.crt")
ctx.verify_mode = ssl.CERT_REQUIRED # this one line turns TLS into mutual TLS
with socket.create_server(("127.0.0.1", 8443)) as srv:
with ctx.wrap_socket(srv, server_side=True) as tls_srv:
while True:
try:
conn, addr = tls_srv.accept() # the handshake happens here
except ssl.SSLError as e:
print("handshake rejected:", e.reason)
continue
with conn:
peer = dict(x[0] for x in conn.getpeercert()["subject"])["commonName"]
print("authenticated client:", peer)
conn.sendall(f"hello {peer}\n".encode())
# labs/mtls_client.py
import socket
import ssl
ctx = ssl.create_default_context(cafile="ca.crt") # trust ONLY our CA for this connection
ctx.load_cert_chain("client.crt", "client.key") # present our identity
with socket.create_connection(("127.0.0.1", 8443)) as raw:
with ctx.wrap_socket(raw, server_hostname="localhost") as tls:
print(tls.version(), tls.recv(100))
uv run labs/mkcerts.py
uv run labs/mtls_server.py &
uv run labs/mtls_client.py # TLSv1.3 b'hello svc-orders\n'
openssl s_client -connect 127.0.0.1:8443 -CAfile ca.crt </dev/null # no client cert: rejected
With httpx: httpx.Client(verify=ssl_ctx) or
httpx.Client(verify="ca.crt", cert=("client.crt", "client.key")). Service
meshes and SPIFFE (Security in Depth Ch 23)
automate exactly this: short-lived client certificates for every service.