Frontend Labs
⋯

Chapter 29 — TLS: how it gets encrypted

In one sentence

TLS runs inside the TCP connection: the two sides negotiate keys, the server proves its identity with a certificate, and everything after that is encrypted — which is what the padlock means.

The problem

You proved it yourself in Chapter 28's practice: plain HTTP is readable by anyone on the path. You need three things:

  1. Confidentiality — nobody can read it
  2. Integrity — nobody can change it undetected
  3. Authentication — you're really talking to example.com, not an impostor

The third is the hardest and the most important. Encryption without authentication is useless — you'd have a perfectly secure channel to an attacker.

Where TLS sits

      HTTP  (your request)
        |
      TLS   (encrypts it)          <-- this chapter
        |
      TCP   (reliable delivery)    <-- Part 5
        |
      IP    (routing)              <-- Part 3

TLS doesn't change TCP at all. It's just the first bytes the application sends after the TCP handshake completes.

How the handshake works (TLS 1.3)

   CLIENT                                              SERVER
     |                                                    |
     |  1. ClientHello                                    |
     |     "I support these ciphers.                       |
     |      Here's my half of a key.                       |
     |      I want to talk to example.com" (SNI)           |
     |     "I can speak HTTP/2 or HTTP/1.1"  (ALPN)        |
     |--------------------------------------------------->|
     |                                                    |
     |  2. ServerHello                                    |
     |     "Let's use this cipher. Here's my half."        |
     |     {Certificate}  <- proves it IS example.com      |
     |     {CertificateVerify}  <- proves it holds the key |
     |     {Finished}                                      |
     |<---------------------------------------------------|
     |     (everything in {} is ALREADY ENCRYPTED)         |
     |                                                    |
     |  3. {Finished}                                      |
     |     + your first encrypted HTTP request             |
     |--------------------------------------------------->|

One round trip in TLS 1.3 (TLS 1.2 needed two). On a repeat visit it can be zero using a resumption ticket.

Three things worth noting:

  • The two "halves of a key" combine into a shared secret that neither could compute alone, and which an eavesdropper cannot derive even having seen both halves. That's Diffie-Hellman key exchange. It also gives forward secrecy: the keys are fresh each time, so stealing the server's long-term key later doesn't decrypt recorded past sessions.
  • SNI (the hostname you want) is sent in plaintext — it's how one IP serves many sites, and it's the main thing still visible to observers. (Encrypted ClientHello is being deployed to fix this.)
  • ALPN negotiates HTTP/2 vs HTTP/1.1 during the handshake, saving a round trip.

Certificates: how trust works

The certificate is the interesting part.

   A certificate says:  "the holder of THIS public key is example.com"
   ...signed by a Certificate Authority (CA).

   Your device ships with a list of ~150 trusted CAs (the "trust store").

   Chain of trust:
       example.com's certificate
            signed by ->  an intermediate CA
                              signed by -> a ROOT CA
                                              already trusted by your device

Your browser checks:

  1. Does the chain lead to a root I trust?
  2. Does the name match the site I asked for?
  3. Is it within its validity dates?
  4. Has it been revoked?

Failing any of these is what produces the scary browser warning — and you should take it seriously, because a valid-looking site with a bad certificate is exactly what an attack looks like.

The versions

Version Status
SSL 2.0 / 3.0 Dead. Broken. Never enable.
TLS 1.0 / 1.1 Deprecated (formally, in 2021). Disable.
TLS 1.2 Fine if configured well. Still widely used.
TLS 1.3 Preferred. Faster (1 round trip), simpler, removed all the broken options.

(Note: people still say "SSL" out of habit — e.g. "SSL certificate" — but the protocol has been TLS since 1999.)

Practice (30 min)

A. Inspect a real handshake:

openssl s_client -connect example.com:443 -servername example.com </dev/null 2>&1 | head -50

Look for: Protocol, Cipher, the certificate chain (0 s: is the site, 1 s: is the intermediate), and Verify return code: 0 (ok).

B. Read the certificate itself:

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

You'll see who it's for, who signed it, when it expires, and which hostnames it covers.

C. Measure the handshake cost:

curl -w 'tcp: %{time_connect}s   tls: %{time_appconnect}s   (tls cost: computed below)\n' \
     -o /dev/null -s https://example.com

time_appconnect − time_connect is what TLS cost you.

D. Watch the handshake in tcpdump. You'll see the ClientHello and ServerHello in the clear, then everything becomes opaque:

sudo tcpdump -i any -n 'tcp port 443 and host example.com' -c 20
curl -s https://example.com > /dev/null

E. See a certificate failure safely:

curl -v https://expired.badssl.com 2>&1 | grep -i -A3 'certificate'
curl -v https://wrong.host.badssl.com 2>&1 | grep -i 'subject\|SSL'

badssl.com is a site specifically built to demonstrate every kind of TLS failure. Explore it in a browser too — very instructive.

F. Check your own site's configuration (if you have one): use ssllabs.com/ssltest — a free, thorough grading tool.

Build it in Go (30 min) — a scriptable openssl s_client

This program completes a real TLS handshake and prints what was negotiated: version, cipher, ALPN, and the certificate chain with days to expiry. More usefully, it turns the four classic certificate failures into specific diagnoses instead of one generic "x509 error".

// tlsinspect: connect, complete a TLS handshake, and print what was negotiated
// and who the server claims to be -- a scriptable `openssl s_client`.
//
//	go run ./tlsinspect example.com
//	go run ./tlsinspect -sni github.com example.com:443   # SNI the edge has no cert for
//	go run ./tlsinspect expired.badssl.com self-signed.badssl.com wrong.host.badssl.com
package main

import (
	"crypto/tls"
	"crypto/x509"
	"errors"
	"flag"
	"fmt"
	"net"
	"os"
	"strings"
	"time"
)

func main() {
	sni := flag.String("sni", "", "override the SNI/hostname to verify (default: the host)")
	flag.Parse()
	exit := 0
	for _, target := range flag.Args() {
		if !strings.Contains(target, ":") {
			target += ":443"
		}
		if err := inspect(target, *sni); err != nil {
			fmt.Printf("%s\n  FAILED: %s\n\n", target, explain(err))
			exit = 1
		}
	}
	os.Exit(exit)
}

func inspect(target, sni string) error {
	host, _, _ := net.SplitHostPort(target)
	if sni == "" {
		sni = host
	}
	dialer := &net.Dialer{Timeout: 5 * time.Second}
	start := time.Now()
	conn, err := tls.DialWithDialer(dialer, "tcp", target, &tls.Config{
		ServerName: sni,                        // sent as SNI AND used to verify the cert
		NextProtos: []string{"h2", "http/1.1"}, // ALPN: what we'd like to speak next
	})
	if err != nil {
		return err
	}
	defer conn.Close()
	st := conn.ConnectionState()

	fmt.Printf("%s  (TCP+TLS in %v)\n", target, time.Since(start).Round(time.Millisecond))
	fmt.Printf("  version  %s\n", tls.VersionName(st.Version))
	fmt.Printf("  cipher   %s\n", tls.CipherSuiteName(st.CipherSuite))
	fmt.Printf("  ALPN     %q\n", st.NegotiatedProtocol)
	fmt.Printf("  resumed  %v\n", st.DidResume)
	for i, c := range st.PeerCertificates {
		role := "leaf"
		if i > 0 {
			role = "intermediate"
		}
		days := int(time.Until(c.NotAfter).Hours() / 24)
		fmt.Printf("  [%d] %-12s CN=%s\n", i, role, c.Subject.CommonName)
		fmt.Printf("      issuer        %s\n", c.Issuer.CommonName)
		fmt.Printf("      valid until   %s  (%d days left)\n", c.NotAfter.Format("2006-01-02"), days)
		if i == 0 {
			fmt.Printf("      SANs          %s\n", strings.Join(c.DNSNames, ", "))
			if days < 21 {
				fmt.Println("      WARNING: renew soon")
			}
		}
	}
	fmt.Printf("  verified chain length: %d (ends at a root in YOUR trust store)\n\n", len(st.VerifiedChains[0]))
	return nil
}

// explain turns crypto/x509 errors into the fingerprints from Chapter 56.
func explain(err error) string {
	var hostErr x509.HostnameError
	var unknown x509.UnknownAuthorityError
	var invalid x509.CertificateInvalidError
	switch {
	case errors.As(err, &hostErr):
		return "NAME MISMATCH: certificate is valid for " + strings.Join(hostErr.Certificate.DNSNames, ", ")
	case errors.As(err, &unknown):
		return "UNKNOWN AUTHORITY: self-signed, private CA, or missing intermediate"
	case errors.As(err, &invalid) && invalid.Reason == x509.Expired:
		return "EXPIRED (or your clock is wrong): " + invalid.Detail
	case strings.Contains(err.Error(), "handshake failure"):
		return "HANDSHAKE REFUSED by server: often an SNI it has no certificate for (" + err.Error() + ")"
	default:
		return err.Error()
	}
}
$ go run ./tlsinspect example.com
example.com:443  (TCP+TLS in 82ms)
  version  TLS 1.3
  cipher   TLS_AES_128_GCM_SHA256
  ALPN     "h2"
  [0] leaf         CN=example.com
      issuer        Cloudflare TLS Issuing ECC CA 3
      valid until   2026-12-25  (83 days left)
      SANs          example.com, *.example.com
  [1] intermediate CN=Cloudflare TLS Issuing ECC CA 3
  ...
$ go run ./tlsinspect expired.badssl.com self-signed.badssl.com wrong.host.badssl.com
expired.badssl.com:443
  FAILED: EXPIRED (or your clock is wrong): "*.badssl.com" certificate is expired
self-signed.badssl.com:443
  FAILED: UNKNOWN AUTHORITY: self-signed, private CA, or missing intermediate
wrong.host.badssl.com:443
  FAILED: NAME MISMATCH: certificate is valid for *.badssl.com, badssl.com
$ go run ./tlsinspect -sni github.com example.com:443
  FAILED: HANDSHAKE REFUSED by server: often an SNI it has no certificate for

What to notice:

  • ServerName does two jobs: it's sent as SNI (so a shared server knows which certificate to present) and it's the name the certificate is checked against. The last example asks a Cloudflare edge for a name it doesn't host, and the edge refuses the handshake before sending any certificate.
  • A wildcard covers exactly one label. *.example.com matches wrong.example.com, so -sni wrong.example.com example.com passes. It does not match a.b.example.com or example.com itself, which is why the SAN list above includes both.
  • ALPN "h2": the server agreed during the handshake to speak HTTP/2 next, with no extra round trip (Chapter 30).
  • The verified chain ends at a root in your trust store, the operating system's on macOS and Linux. That's why "works on my laptop, fails in the container" happens: minimal container images often have no CA bundle at all.

Exercises:

  1. Add an -expiry-days 30 flag that exits non-zero if any leaf expires within 30 days, and run it from cron against all your endpoints. That is a certificate-expiry monitor. Chapter 56 turns it into an operational practice.
  2. Connect twice in one process with a shared tls.Config that has a ClientSessionCache (tls.NewLRUClientSessionCache(10)). Does resumed become true the second time? Not if you close straight after the handshake. In TLS 1.3 the server sends its session ticket after the handshake, so the client only receives it if it reads from the connection. Send a HEAD request and read the reply on the first connection, and the second one resumes. (Tested: resumed=false without the read, resumed=true with it.)
  3. Go deeper with the Go plan: TLS inspector + cert monitor (Day 38), a full PKI generator (Day 40), and mTLS with identity extraction (Day 41).

Across the series: the HTTPS guide's TLS chapter explains the handshake from the browser's side, and the Go guide's TLS chapter covers crypto/tls server configuration.

Common confusions

  • "The padlock means the site is safe." It means the connection is encrypted and the name matches. A phishing site can have a perfectly valid certificate for its own (misleading) domain.
  • "HTTPS hides everything." It hides the URL path, headers, and content. It does not hide which server you're talking to (IP), or the hostname (SNI, for now), or the size and timing of your traffic.
  • "Do I need to buy a certificate?" No — Let's Encrypt issues them free and automatically. There's no security difference for standard certificates.
  • "Certificate expired" outages are extremely common and entirely preventable. Automate renewal and monitor expiry dates.

Check yourself

  1. What three guarantees does TLS provide?
  2. Why is authentication the most important one?
  3. What is SNI, and what does it leak?
  4. Walk through what your browser checks in a certificate.

Further reading

  • Book (free): High Performance Browser Networking, Chapter 4 (Transport Layer Security). Excellent and readable.
  • Site: badssl.com — every kind of TLS failure, safely demonstrated.
  • Tool: ssllabs.com/ssltest — grade any HTTPS site and see exactly why.
  • Video: "TLS Handshake Explained" — Practical Networking has a superb series on TLS and cryptography basics; also Computerphile's "Diffie Hellman" video for the key-exchange intuition (uses paint mixing — genuinely clarifying).
  • Article: "A Complete Overview of SSL/TLS and its Cryptographic System" or Cloudflare's "What happens in a TLS handshake?"

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