Module 09: TLS/SSL and HTTPS

Why this matters

Every https:// URL, every green padlock, every curl -v line that said TLS handshake since module 00 — that's the security layer this module finally opens up. HTTPS is not a different protocol from HTTP; it's plain HTTP (everything you learned in modules 01-08) running inside an encrypted, authenticated tunnel provided by TLS. Understanding that relationship — "HTTPS = HTTP over TLS" — dissolves most of the confusion people carry about certificates, the padlock, and why "the site is secure."

Why a backend engineer must know this beyond "just turn on HTTPS": you'll configure TLS on servers and load balancers, debug certificate errors that take down APIs (certificate has expired, hostname mismatch, unable to verify the first certificate), decide where TLS terminates in your architecture (module 00's proxy layer), and reason about the security headers from module 02 (HSTS) that depend on it. And when a security review asks "is data encrypted in transit?", you'll know exactly what TLS does and does not protect.

The three things TLS actually gives you — encryption (eavesdroppers can't read it), integrity (tampering is detected), and authentication (you're really talking to example.com, not an impostor) — are worth internalizing precisely, because people routinely assume HTTPS provides guarantees it doesn't (it doesn't hide which site you visited, and it doesn't vouch that the site is trustworthy, only that it's who it claims to be).

Concepts

SSL vs TLS: the naming

SSL (Secure Sockets Layer) is the original 1990s protocol; TLS (Transport Layer Security) is its successor and what everyone actually uses today. SSL 2.0 and 3.0 are long broken and disabled. "SSL certificate," "SSL termination," "SSL/TLS" — in modern usage these all mean TLS; the name "SSL" stuck culturally. Current versions: TLS 1.2 (still common) and TLS 1.3 (2018, faster and more secure — fewer handshake round trips, weak options removed). When you see "SSL," read "TLS."

HTTPS = HTTP over TLS

The layering, bottom to top (module 00's journey, annotated):

[ HTTP request/response ]   ← everything from modules 01-08 (unchanged)
        rides inside
[ TLS record layer ]        ← encrypts + authenticates the bytes
        rides on
[ TCP connection ]          ← reliable byte transport
        rides on
[ IP packets ]              ← routed across the internet

TLS sits between TCP and HTTP. Your HTTP message is identical whether it's HTTP or HTTPS — the difference is that with HTTPS, before any HTTP flows, a TLS handshake establishes an encrypted channel, and then the exact same HTTP bytes travel through it, opaque to anyone in the middle. This is why nothing in modules 01-08 changed for HTTPS: TLS is transparent to the HTTP semantics above it. The default port is 443 for HTTPS vs. 80 for plain HTTP.

What TLS provides (and what it doesn't)

TLS gives three guarantees:

  • Confidentiality (encryption): anyone intercepting the bytes (your ISP, someone on the same Wi-Fi, a router along module 00's path) sees only ciphertext, not your data, cookies, or tokens.
  • Integrity: if anyone alters the bytes in transit, the tampering is detected and the connection fails — you can't silently modify an HTTPS response.
  • Authentication (of the server): the certificate proves you're talking to the real example.com, not an impostor who intercepted your connection (a man-in-the-middle).

What TLS does not give you:

  • It does not hide which server you connected to. An observer still sees the destination IP, and historically the hostname (via SNI, below) in the clear. It hides the content, not the fact of the connection.
  • It does not vouch that a site is trustworthy or safe — only that it is who its certificate says it is. https://scam-site.example can have a perfectly valid certificate. The padlock means "encrypted and authenticated," not "honest."
  • It does not protect data at rest on the server or after the TLS terminates (e.g. inside your datacenter past the load balancer, unless you re-encrypt).

Certificates, CAs, and the chain of trust

Server authentication rests on certificates and a chain of trust:

  • A certificate binds a public key to an identity (a domain name like example.com) and is digitally signed by a Certificate Authority (CA). It contains the domain(s) it's valid for, a validity period (not-before / not-after dates), the public key, and the CA's signature.
  • A CA is an organization your system already trusts (Let's Encrypt, DigiCert, etc.). Your OS/browser ships with a trust store of root CA certificates.
  • The chain: the server presents its certificate plus intermediate CA certificate(s); each is signed by the next up the chain, terminating at a root CA in your trust store. Your client verifies each signature up to a trusted root. If the chain is complete and valid, the identity is trusted.
   leaf cert           intermediate CA        root CA (in your trust store)
  ┌──────────────┐    ┌──────────────┐        ┌──────────────┐
  │ CN=example.com│   │ CN=R3        │        │ CN=ISRG Root │
  │ signed by ────┼──►│ signed by ───┼───────►│ self-signed  │ ◄─ already trusted
  └──────────────┘    └──────────────┘        └──────────────┘
   server presents leaf + intermediate; client walks signatures up to a root

Certificates expire (Let's Encrypt certs last 90 days) — hence auto-renewal, and hence the classic outage: a cert expires unnoticed and every client suddenly rejects the connection. Certs are also revocable (compromised keys).

The TLS handshake, conceptually

When you connect to https://example.com (module 00's step 4), before any HTTP:

  1. ClientHello: the client says "I support these TLS versions and cipher suites," offers a key-exchange share, and — crucially — names the host it wants via SNI (Server Name Indication), so a server hosting many domains on one IP knows which certificate to present.
  2. ServerHello + Certificate: the server picks a version/cipher, sends its certificate chain, and its own key-exchange share.
  3. Verification: the client validates the certificate chain against its trust store, checks the hostname matches, and checks the dates.
  4. Key agreement: both sides derive the same shared session keys using the exchanged shares (modern TLS uses ephemeral Diffie-Hellman, giving forward secrecy — even if the server's long-term key later leaks, past sessions stay unreadable).
  5. Encrypted channel established: from here, all HTTP flows encrypted.
   CLIENT                                              SERVER
     │ ── ClientHello ────────────────────────────────► │  versions, ciphers,
     │    (TLS versions, ciphers, key share, SNI, ALPN)  │  key share, SNI=host
     │ ◄──────────── ServerHello + Certificate chain ─── │  picks version/cipher,
     │               + server key share                  │  sends cert + key share
     │  verify: chain → trusted root? hostname? dates?   │
     │  derive shared session keys (ephemeral DH)        │
     │ ═══════════ encrypted channel established ═══════ │
     │ ── GET / HTTP/1.1 (now encrypted) ──────────────► │  ← HTTP finally flows

TLS 1.3 streamlined this to a single round trip (1-RTT), and supports 0-RTT resumption — part of why it's faster, and echoing module 07's point that QUIC/HTTP-3 folds this handshake in for even fewer round trips.

Also recall ALPN from module 07: the handshake is where client and server agree on h2 vs http/1.1. So a single TLS handshake does triple duty — secure the channel, prove identity, and negotiate the HTTP version.

When validation fails: name the check, find the owner

A certificate error is a validation failure, not a connection failure: if TCP to port 443 completes (nc -zv host 443) and curl -v shows the handshake starting, the network is fine and firewalls are not your problem. Almost every cert error is one of four checks, and each has a different owner and fix:

Failed check Typical message Who fixes it
Name mismatch (host not in the cert's CN/SAN) no alternative certificate subject name matches Client connects by the name on the cert, or the owner reissues with the right SAN
Expired / not yet valid certificate has expired Server operator renews; or your clock is wrong
Untrusted issuer (self-signed, private CA) self-signed certificate, unable to get local issuer certificate Client adds the CA to its trust store
Incomplete chain (intermediate not sent) unable to verify the first certificate Server operator configures the full chain

Two tells worth remembering: if many unrelated HTTPS sites fail at once, check date before blaming servers (a skewed clock makes every cert look expired); and "works in my browser, fails in curl or a container" usually means a missing intermediate that the browser had cached from an earlier visit.

Where TLS terminates in your architecture

Recall module 00's stack (load balancer → reverse proxy → app server). TLS termination is the point where encrypted traffic is decrypted:

  • Most commonly, TLS terminates at the load balancer or reverse proxy (nginx, a cloud LB, a CDN). It holds the certificate/private key, decrypts, and forwards plain HTTP to your app over the trusted internal network. This is why your FastAPI app usually sees http:// internally even though users used https:// — and why the X-Forwarded-Proto: https header (module 00/02) exists to tell your app the original request was secure.
  • For higher security, some setups do end-to-end TLS (re-encrypt to the backend) so traffic is never plaintext even inside the datacenter.

Knowing where termination happens tells you where the certificate lives, where to debug TLS errors, and why redirect-to-HTTPS logic often belongs at the proxy, not the app.

HSTS: forcing HTTPS

The Strict-Transport-Security header from module 02 is TLS's enforcement partner: Strict-Transport-Security: max-age=31536000; includeSubDomains tells the browser "for the next year, only ever connect to me over HTTPS, even if the user types http:// or clicks an http link." This defeats SSL stripping attacks (a MITM downgrading you to plaintext before the redirect). HSTS only makes sense once HTTPS works everywhere for your site.

Command reference

Command What it does
curl -v https://example.com/ 2>&1 | grep -iE 'TLS|SSL|certificate|subject|ALPN' See the handshake, cert, and negotiated protocol
openssl s_client -connect example.com:443 -servername example.com Full handshake + certificate chain details
openssl s_client -connect example.com:443 </dev/null 2>/dev/null | openssl x509 -noout -dates Show the certificate's validity dates
openssl s_client -connect example.com:443 -servername example.com </dev/null 2>/dev/null | openssl x509 -noout -subject -issuer Show who the cert is for and who issued it
curl --cacert ca.pem https://... Verify against a specific CA bundle
curl -k https://... Skip certificate verification (DANGER: debugging only)

Notes:

  • openssl s_client -connect host:443 -servername host is the Swiss-army tool. -servername sends SNI (needed for multi-domain hosts). The output shows the offered certificate chain, the negotiated protocol/cipher, and verification result (Verify return code: 0 (ok)).
  • openssl x509 -noout -dates extracts notBefore/notAfter — how you check for an expired or not-yet-valid cert (the classic outage).
  • curl -k (--insecure) disables verification. It's for debugging a cert problem only — using it in real code throws away TLS's authentication guarantee and invites man-in-the-middle attacks.
  • The default trust store is your OS's; --cacert points at a specific CA (used for internal/self-signed CAs).

To serve HTTPS locally (used in exercises), generate a self-signed cert and run uvicorn with TLS:

openssl req -x509 -newkey rsa:2048 -nodes \
  -keyout key.pem -out cert.pem -days 1 \
  -subj "/CN=localhost"
uvicorn app:app --host 127.0.0.1 --port 8443 \
  --ssl-keyfile key.pem --ssl-certfile cert.pem

Hands-on exercises

You'll use curl, openssl, and a local HTTPS server. Most exercises hit public sites; a couple use a self-signed cert to see verification fail.

1. Watch a real TLS handshake

curl -v https://example.com/ 2>&1 | grep -iE 'TLS|SSL|ALPN|certificate|subject:|issuer:|SSL connection' | head -20

Expected: lines showing the TLS version (TLSv1.3), the ALPN-negotiated protocol (h2 or http/1.1, module 07), the server certificate's subject (the domain) and issuer (the CA), and SSL connection using TLSv1.3 / <cipher>. You're seeing module 00's step 4 in full.

2. Inspect the certificate chain and dates

openssl s_client -connect example.com:443 -servername example.com </dev/null 2>/dev/null \
  | openssl x509 -noout -subject -issuer -dates

Expected: subject= (the domain the cert vouches for), issuer= (the CA), and notBefore/notAfter dates. Confirm today's date falls inside the validity window. This is exactly what a client checks in handshake step 3.

3. See the full chain of trust

openssl s_client -connect example.com:443 -servername example.com -showcerts </dev/null 2>/dev/null \
  | grep -E 's:|i:'

Expected: multiple certificates — the leaf (server) cert, whose issuer (i:) matches the subject (s:) of the next intermediate, and so on toward a root CA in your trust store. That linkage is the chain of trust.

4. Serve HTTPS locally with a self-signed cert

Use any earlier FastAPI app.py. Generate the cert and run (commands in the reference above). Then:

curl -v https://127.0.0.1:8443/ 2>&1 | grep -iE 'certificate|verify|SSL|self'

Expected: curl reports it established TLS but the certificate is self-signed / not from a trusted CA — a verification failure like SSL certificate problem: self-signed certificate. The encryption worked; the authentication failed because no trusted CA signed it.

5. Understand why -k is dangerous

curl -k -s https://127.0.0.1:8443/ | head -c 60; echo

Expected: it now succeeds — because -k told curl to skip verification. Reason about what you gave up: you no longer have any proof you're talking to the right server. On the public internet, -k would let a man-in-the-middle present any cert and you'd accept it. -k is a debugging crutch, never a fix.

6. Prove hostname matching matters

# Connect to a valid site but claim you expected a different hostname
curl -v --resolve wrong.example.com:443:$(dig +short example.com | head -1) \
  https://wrong.example.com/ 2>&1 | grep -iE 'certificate|does not match|subject' | head

Expected: a hostname-mismatch error — the certificate is valid but issued for example.com, not wrong.example.com, so verification fails. Authentication isn't just "is the cert valid" — it's "is it valid for this hostname." (This is the SNI/subject-matching check.)

7. See SNI in action

# Without SNI on a multi-tenant host, the wrong/default cert may come back
openssl s_client -connect example.com:443 </dev/null 2>/dev/null | openssl x509 -noout -subject
openssl s_client -connect example.com:443 -servername example.com </dev/null 2>/dev/null | openssl x509 -noout -subject

Expected: with -servername (SNI), the server knows which domain you want and returns the matching cert. On shared hosting, omitting SNI can return a default/mismatched cert. SNI is how one IP serves many HTTPS sites (the TLS analog of the Host header from module 01).

8. Diagnose and fix: the expired-certificate outage

Regenerate a cert that is already expired to reproduce the classic production incident:

# A cert valid only in the past (backdated, 1-day lifetime already over)
openssl req -x509 -newkey rsa:2048 -nodes -keyout key.pem -out cert.pem \
  -subj "/CN=localhost" -days 1 -not_before 20200101000000Z -not_after 20200102000000Z 2>/dev/null || \
faketime '2 days ago' openssl req -x509 -newkey rsa:2048 -nodes -keyout key.pem -out cert.pem -subj "/CN=localhost" -days 1
uvicorn app:app --host 127.0.0.1 --port 8443 --ssl-keyfile key.pem --ssl-certfile cert.pem &
sleep 2
curl -v https://127.0.0.1:8443/ 2>&1 | grep -iE 'expired|certificate|verify'

Expected: certificate has expired (or similar). Diagnose: the cert's notAfter is in the past, so every client rejects the connection — this is exactly the real-world outage where "the whole API went down at midnight" because a cert expired and auto-renewal failed. Check it with openssl x509 -noout -dates -in cert.pem. Fix: issue a currently-valid cert (the normal command from the reference, -days 365), restart, and confirm curl connects (modulo the self-signed trust issue from exercise 4). Lesson: certificates expire; monitor expiry and automate renewal. (Stop the server: kill %1.)

9. Diagnose real broken certificates

badssl.com hosts deliberately misconfigured sites. For each, first prove the transport works, then name the failed check:

for h in expired wrong.host self-signed untrusted-root incomplete-chain; do
  nc -zvw3 $h.badssl.com 443 2>&1 | tail -1          # TCP OK?
  curl -sS -o /dev/null https://$h.badssl.com/ 2>&1 | head -1   # which check failed?
done
echo | openssl s_client -connect expired.badssl.com:443 -servername expired.badssl.com 2>/dev/null \
  | openssl x509 -noout -dates

Expected: every nc line says the connection succeeded, yet every curl fails with a different message that maps to one row of the table in Concepts (expired → expiry, wrong.host → name mismatch, self-signed and untrusted-root → untrusted issuer, incomplete-chain → incomplete chain). The openssl line shows a notAfter in the past. The skill is naming which check failed, because that tells you who has to fix it. (Some clients complete a missing chain on their own, so incomplete-chain may pass in a browser while failing in curl.)

Independent challenge

No code given.

Task: Take one FastAPI app you built in an earlier module and put it behind proper HTTPS locally, then produce evidence for each of the three guarantees TLS provides and each of the two guarantees it does not. Specifically: (a) demonstrate that the HTTP request/response semantics (module 01-08 — methods, status codes, headers) are byte-for-byte the same over HTTPS as over HTTP, proving TLS is transparent to HTTP; (b) show your server's certificate's subject, issuer, and validity window and explain which handshake step checks each; (c) explain, referencing where TLS terminates (module 00's proxy layer) and the X-Forwarded-Proto header (module 02), how a load-balanced production app would know a request originally arrived over HTTPS even though the app itself received plain HTTP; and (d) state one thing an eavesdropper can still learn about your HTTPS traffic despite encryption. Reference SNI and the Host header (module 01) parallel by name.

Hint

For (a), run the same curl -sD - -o /dev/null against the HTTP and HTTPS versions of an endpoint and diff the status line + headers — identical. For (c), the pattern is: TLS terminates at the LB/proxy, which sets X-Forwarded-Proto: https before forwarding plain HTTP to the app; the app reads that header to know the original scheme. For (d), the destination IP (and, unless encrypted-SNI is used, the hostname via SNI) is visible even though the content is not.

Common mistakes & troubleshooting

  • Thinking HTTPS is a different protocol. It's HTTP inside a TLS tunnel; the HTTP semantics are unchanged. TLS sits between TCP and HTTP.
  • Using curl -k (or disabling verification in code) as a "fix." It throws away authentication and enables man-in-the-middle attacks. Fix the cert/chain instead.
  • Believing the padlock means the site is trustworthy. It means encrypted + authenticated identity, nothing about honesty. Scam sites can have valid certs.
  • Forgetting certs expire. Unmonitored expiry is a top cause of sudden total outages. Automate renewal and alert on upcoming expiry.
  • Incomplete chain (missing intermediate). unable to verify the first certificate usually means the server didn't send the intermediate cert; clients can't build the chain to a trusted root. Configure the full chain.
  • Debugging firewalls for a certificate error. If nc -zv host 443 succeeds, the network is fine; the failure is validation. Read curl's message and match it to one of the four checks.
  • Ignoring the client's clock. A badly skewed clock makes every cert look expired or not-yet-valid. If everything breaks at once, check date.
  • Hostname mismatch. A valid cert for the wrong domain still fails. The cert must cover the exact hostname (or via a wildcard/SAN).
  • Assuming the app sees HTTPS when TLS terminates at a proxy. The app gets plain HTTP; trust X-Forwarded-Proto (from a proxy you control) to learn the original scheme.
  • Confusing SSL and TLS. "SSL certificate"/"SSL termination" mean TLS today; actual SSL 2/3 are disabled and insecure.

Checkpoint quiz

Write down your answer to each question before expanding it — checking without attempting first is the single easiest way to fool yourself into thinking you've learned this.

  1. What is the precise relationship between HTTP, HTTPS, TLS, and TCP? Where in the stack does TLS sit?
  2. Name the three guarantees TLS provides, and two things it explicitly does not protect.
  3. What does a certificate bind together, who signs it, and what is the "chain of trust"?
  4. During the handshake, name three things the client checks about the server's certificate before trusting it.
  5. What does SNI do, and which HTTP/1.1 header is it the TLS-layer parallel of?
  6. Your production app receives plain http:// internally but users connect via https://. Explain where TLS terminated and how the app can still know the original request was HTTPS.
  7. Why is curl -k acceptable for a quick local debug but dangerous in real client code?
  8. A previously-working API suddenly rejects all clients at 00:00 with a TLS error. What's the most likely single cause, and how do you confirm it?
Answers
  1. HTTPS is HTTP running over TLS. TLS sits between TCP (below) and HTTP (above): TCP carries bytes, TLS encrypts/authenticates them, and the unchanged HTTP messages flow inside the TLS tunnel.
  2. Provides: confidentiality (encryption), integrity (tamper detection), and server authentication (you're talking to the real host). Does not: hide which server/IP (or hostname via SNI) you connected to, and does not vouch that the site is trustworthy/safe (only that it's who it claims).
  3. A certificate binds a public key to an identity (a domain name); it's signed by a Certificate Authority (CA). The chain of trust is the sequence of signatures from the server (leaf) cert through intermediate CA cert(s) up to a root CA already trusted in the client's trust store.
  4. Any three: that the chain validates up to a trusted root CA; that the hostname matches the cert's subject/SAN; and that the current time is within the cert's validity window (not expired / not-yet-valid). (Also: not revoked.)
  5. SNI (Server Name Indication) tells the server, during the handshake, which hostname the client wants, so a server with many domains on one IP presents the right certificate. It's the TLS-layer parallel of the HTTP/1.1 Host header (module 01).
  6. TLS terminated at the load balancer/reverse proxy, which decrypted and forwarded plain HTTP to the app over the internal network. The proxy sets X-Forwarded-Proto: https, which the app reads to know the original scheme was HTTPS.
  7. -k skips certificate verification, so for a local self-signed cert it's a convenient debugging shortcut. In real code it removes the authentication guarantee — an attacker could present any cert as a man-in-the-middle and you'd accept it.
  8. An expired certificate (its notAfter passed, likely a failed auto-renewal). Confirm with openssl x509 -noout -dates (or openssl s_client ... | openssl x509 -noout -dates) and check notAfter.

Further reading & sources

Free PDF notes

Download the notes for each video on this page as a printable PDF, with every diagram and interview questions: SD-B-011: HTTPS & TLS 1.3 Handshake, SD-B-012: Certificates & PKI. Each section in one file: Phase 2, Networking (PDF).

Next

10-routing-fundamentals — the request has arrived, decrypted and understood; now we cover how the server decides which code handles it: routing URLs and methods to handlers.