Learning Goals
3 minBy the end of this lesson you can:
- Explain what TLS provides: encryption, authentication, integrity.
- Describe the TLS handshake and the role of certificates and CAs.
- Wrap a client socket in TLS with proper certificate verification.
- Generate a self-signed cert and run a TLS server (for your lab).
Warm-Up · Three Problems TLS Solves
5 minPlain TCP (Lessons 7-8) sends bytes in the clear. On open wifi, anyone can read them, change them, or impersonate the server. TLS fixes all three:
CONFIDENTIALITY encryption → eavesdroppers see only ciphertext AUTHENTICATION certificates → you're really talking to the right server INTEGRITY message auth → tampering is detected and rejected
TLS (Transport Layer Security — "SSL" is its obsolete predecessor) is a thin secure layer you wrap a normal socket in. The magic is the handshake: the parties agree on encryption keys and the client verifies the server's identity via a certificate signed by a trusted authority. Skip that verification and you have encryption to an unknown party — which an attacker is happy to be.
New Concept · The Handshake, Certs & Verification
14 minThe TLS handshake (simplified)
Client Server │ ── ClientHello ────────────────────────► │ "I support these ciphers" │ ◄──── ServerHello + CERTIFICATE ───────── │ "let's use this cipher; here's my cert" │ verify cert (signed by a trusted CA?) │ │ for the right hostname? not expired? │ │ ── key exchange ──────────────────────► │ agree on a shared secret │ ════════ encrypted channel ════════════ │ (all further data encrypted)
The certificate is the heart of it. The server presents one; the client checks it's (1) signed by a trusted Certificate Authority, (2) issued for the hostname you're connecting to, and (3) not expired or revoked. Only then does it trust the channel.
Certificates & chains of trust
Root CA (pre-installed, trusted)
└─ signs → Intermediate CA
└─ signs → example.com's certificate
Your machine trusts the Root → so it trusts the whole chain down to example.com.Your OS/browser ships with a list of trusted root CAs. A cert is trusted if it chains up to one of them. That's why you can't just make your own cert for google.com — no trusted CA will sign it.
TLS client — with verification (the right way)
import socket, ssl # create_default_context() turns ON verification + hostname check by default context = ssl.create_default_context() with socket.create_connection(("example.com", 443), timeout=10) as sock: with context.wrap_socket(sock, server_hostname="example.com") as tls: print("TLS version:", tls.version()) # e.g. TLSv1.3 cert = tls.getpeercert() print("issued to:", dict(x[0] for x in cert["subject"])) tls.sendall(b"GET / HTTP/1.1\r\nHost: example.com\r\n" b"Connection: close\r\n\r\n") print(tls.recv(200).decode(errors="replace"))
create_default_context() is the safe default: it verifies the certificate chain and checks the hostname matches. wrap_socket turns your plain socket into an encrypted one. This is what requests does internally.
⚠️ The most dangerous line in security code
You'll see verify=False (requests) or context.check_hostname=False; context.verify_mode=ssl.CERT_NONE in tutorials and Stack Overflow answers. This encrypts the traffic to whoever answers — which is exactly what a man-in-the-middle attacker wants. You get a false sense of security: a padlock to an impostor. Disabling verification defeats the entire point of TLS. The only acceptable use is connecting to your own self-signed lab server, where you explicitly load_verify_locations(your_cert) instead.
Self-signed certs for your lab
# generate a self-signed cert + key for localhost (run once, in your lab): # openssl req -x509 -newkey rsa:2048 -keyout key.pem -out cert.pem \ # -days 365 -nodes -subj "/CN=localhost" # TLS SERVER side: context = ssl.SSLContext(ssl.PROTOCOL_TLS_SERVER) context.load_cert_chain(certfile="cert.pem", keyfile="key.pem") # wrap the listening socket's accepted connections with context.wrap_socket(conn, server_side=True)
A self-signed cert isn't trusted by any CA, so browsers warn about it — fine for a private lab where you control both ends and point the client at your own cert. Never ship self-signed certs to real users.
Worked Example · Inspect a Real Cert & Build a TLS Echo
12 minFirst, a tool that connects to a public HTTPS site and reports its certificate details — useful for auditing your own sites' TLS health.
import socket, ssl from datetime import datetime def inspect_tls(host: str, port: int = 443) -> None: context = ssl.create_default_context() with socket.create_connection((host, port), timeout=10) as sock: with context.wrap_socket(sock, server_hostname=host) as tls: cert = tls.getpeercert() subject = dict(x[0] for x in cert["subject"]) issuer = dict(x[0] for x in cert["issuer"]) expires = datetime.strptime(cert["notAfter"], "%b %d %H:%M:%S %Y %Z") days_left = (expires - datetime.utcnow()).days print(f"=== {host} ===") print("TLS version:", tls.version()) print("issued to: ", subject.get("commonName")) print("issued by: ", issuer.get("organizationName")) print("expires: ", expires.date(), f"({days_left} days left)") if days_left < 30: print("⚠️ certificate expiring soon!") inspect_tls("example.com")
=== example.com === TLS version: TLSv1.3 issued to: *.example.com issued by: DigiCert Inc expires: 2026-09-15 (110 days left)
A TLS echo server + client (lab)
# server.py — wraps the Lesson 8 server in TLS import socket, ssl ctx = ssl.SSLContext(ssl.PROTOCOL_TLS_SERVER) ctx.load_cert_chain("cert.pem", "key.pem") # your self-signed lab cert with socket.create_server(("127.0.0.1", 9443)) as server: with ctx.wrap_socket(server, server_side=True) as tls_server: print("TLS server on 127.0.0.1:9443") conn, addr = tls_server.accept() # already encrypted with conn: print(conn.recv(1024).decode()) # cleartext to us, ciphertext on the wire conn.sendall(b"securely received") # client.py — trusts ONLY our own cert (the right way for self-signed) ctx = ssl.create_default_context() ctx.load_verify_locations("cert.pem") # trust our lab cert explicitly ctx.check_hostname = True with socket.create_connection(("127.0.0.1", 9443)) as sock: with ctx.wrap_socket(sock, server_hostname="localhost") as tls: tls.sendall(b"hello over TLS") print(tls.recv(1024).decode())
Read the code
The inspector audits TLS health — version, issuer, expiry — which you'd run against your own sites (an expiring cert is a real outage waiting to happen). The echo pair shows the key principle for self-signed labs: instead of disabling verification, the client load_verify_locations("cert.pem") to explicitly trust that one cert — keeping verification on, just pointed at your own authority. On the wire it's ciphertext; in your code it's plaintext bytes. This is the foundation for the encrypted chat next lesson.
Try It Yourself
13 minRun inspect_tls on three HTTPS sites (your own, a big one, and one you're curious about). Report TLS version, issuer, and days-to-expiry for each. Flag any using old TLS (<1.2) or expiring within 30 days.
Generate a self-signed cert for localhost (the openssl one-liner), run the TLS echo server, and connect with the verifying client. Confirm an encrypted round-trip.
Hint (openssl)
# one command — creates cert.pem + key.pem valid for 1 year: # openssl req -x509 -newkey rsa:2048 -keyout key.pem -out cert.pem \ # -days 365 -nodes -subj "/CN=localhost"
Connect your verifying client to a site whose cert doesn't match the hostname (e.g. connect to wrong.host.badssl.com or use https://self-signed.badssl.com). Confirm it raises ssl.SSLCertVerificationError. Then explain in comments why "fixing" it with verify=False would be dangerous.
Hint
# badssl.com hosts intentionally-broken certs FOR testing (safe). import ssl, socket ctx = ssl.create_default_context() try: with socket.create_connection(("self-signed.badssl.com", 443), timeout=10) as s: ctx.wrap_socket(s, server_hostname="self-signed.badssl.com") except ssl.SSLCertVerificationError as e: print("correctly rejected:", e) # Disabling verify would ACCEPT this untrusted cert — the exact # foothold a man-in-the-middle needs.
Mini-Challenge · A TLS Health Checker
8 minBuild tls_health(domains) that, for a list of your own domains, reports a pass/warn/fail grade per site: FAIL if cert is invalid/expired or TLS < 1.2, WARN if expiring within 30 days, PASS otherwise. This is a real monitoring tool that prevents the embarrassing "our cert expired" outage.
Show a sample solution
import socket, ssl from datetime import datetime def tls_health(domains: list[str]) -> None: for host in domains: try: ctx = ssl.create_default_context() with socket.create_connection((host, 443), timeout=10) as s: with ctx.wrap_socket(s, server_hostname=host) as tls: ver = tls.version() cert = tls.getpeercert() exp = datetime.strptime(cert["notAfter"], "%b %d %H:%M:%S %Y %Z") days = (exp - datetime.utcnow()).days old_tls = ver in ("TLSv1", "TLSv1.1", "SSLv3") grade = ("FAIL" if (days < 0 or old_tls) else "WARN" if days < 30 else "PASS") print(f"{grade:4} {host:25} {ver} {days}d to expiry") except Exception as e: print(f"FAIL {host:25} {type(e).__name__}: {e}") tls_health(["example.com", "wikipedia.org"])
Non-negotiables: verification ON, grades on expiry + TLS version, exceptions become FAIL (not crashes).
Recap
3 minTLS gives a connection three properties — confidentiality (encryption), authentication (certificates), and integrity (tamper detection) — by wrapping a normal socket. The handshake agrees on keys and verifies the server's certificate against trusted CAs and the hostname. In Python, ssl.create_default_context() + wrap_socket does this the safe way, with verification on by default. Never disable verification — it turns TLS into encryption to an unknown (possibly malicious) party; for self-signed lab certs, load_verify_locations your own cert instead. Audit your sites' cert expiry to avoid outages.
Vocabulary Card
- TLS / SSL
- The protocol securing connections; SSL is its obsolete predecessor.
- certificate / CA
- A signed proof of identity / the trusted authority that signs it.
- handshake
- The negotiation that agrees keys and verifies the server.
- verification
- Checking the cert is trusted, for the right host, and unexpired — never disable it.
Homework
4 minBuild the TLS health checker and run it on every domain you own. Then build the self-signed TLS echo pair and confirm an encrypted round-trip with verification on (trusting your own cert). Write a paragraph explaining, to a beginner, exactly why verify=False is dangerous and what to do instead for a self-signed server.
Sample · why verify=False is dangerous
TLS does TWO things: encrypt the data AND prove WHO you're talking
to. verify=False keeps the encryption but throws away the proof of
identity. So you get a padlock — but it might be a padlock to an
attacker sitting in the middle (a coffee-shop wifi, a poisoned DNS).
They present ANY cert, you accept it, they decrypt everything you
send and forward it on. You feel secure and are completely exposed.
For a self-signed server you control, DON'T disable verification.
Instead keep it ON and tell the client to trust exactly your cert:
ctx = ssl.create_default_context()
ctx.load_verify_locations("cert.pem")
Now only YOUR server's cert is accepted — secure, and no impostor
can slip in.Non-negotiables: working health checker on real domains, a verified self-signed round-trip, and a clear beginner-level explanation of the verify=False danger + the correct alternative.