Frontend Labs
⋯

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.


Generated from real-life-python-guide.md · 8 Oct 2026 16:36