L10 · The TLS Handshake
Module 2: Networking for Defenders · ~20 min · Lesson 10 of 71
🎯 What you'll learn
- The three things TLS guarantees, and which attacks each one stops
- The TLS 1.3 handshake step by step, and why it needs only one round trip
- How ephemeral key exchange (ECDHE) gives you forward secrecy
- What TLS does not protect, and where it ends in a client → ALB → Kong → EKS stack
🔥 Why this matters
A banking app sends POST /v1/transfers with a bearer token and an account number. Before it
reaches the service's pod, it crosses café Wi-Fi, an ISP, the AWS edge, a load balancer, and Kong. TLS is
the reason nobody along that path can read the token or change the amount. But "we use HTTPS"
often hides two questions: where does the encryption stop, and what was it never meant to
protect? In L06, Equifax's TLS inspection box quietly stopped inspecting because one certificate
expired. Knowing where TLS starts and ends is how you avoid that kind of blind spot.
1. What TLS promises: three guarantees
Picture a sealed, tamper-evident courier pouch that only you and the bank can open, delivered by
a courier whose ID you checked. That's the idea. TLS (Transport Layer Security) is the
protocol that wraps a TCP connection (L07) in that pouch. It's the "S" in HTTPS, and it also
protects Kong mTLS hops, MongoDB connections with tls=true, and AWS API calls.
Precisely, TLS gives you three properties for data in transit, each from L01:
| Guarantee | Stops | How |
|---|---|---|
| Confidentiality | Eavesdropping on tokens, PII, balances | Records are encrypted with session keys |
| Integrity | Flipping bits or changing amounts in flight | Every record carries an AEAD tag (L01 deep dive) |
| Server authenticity | A fake api.example.com |
The server proves it holds the key in its certificate |
The client can prove its identity too, with mutual TLS (mTLS), but that's optional and comes in L11. By default only the server is authenticated.
2. The TLS 1.3 handshake, one round trip
Before any HTTP flows, both sides run a handshake: they agree on a version and algorithms, create shared keys, and the server proves who it is. TLS 1.3 (RFC 8446, 2018) does this in one round trip, called 1-RTT. TLS 1.2 needed two.
Client (your app) Server (api.example.com)
│ │
│ ClientHello │
│ · versions I support: 1.3, 1.2 │
│ · cipher suites (AEAD only) │
│ · key_share: my ephemeral public key │
│ · SNI: "api.example.com" (plaintext!) │
│───────── 1. hello + key share ────────────▶│
│ │
│ ServerHello │
│ · picked: TLS 1.3, suite │
│ · key_share: my ephemeral │
│ public key │
│◀──────── 2. hello + key share ─────────────│
│ │
│ both sides compute the same secret now │
│ ═══════ everything below is encrypted ════ │
│ │
│ {EncryptedExtensions} │
│ {Certificate} who I am │
│ {CertificateVerify} my signature │
│ over the │
│ handshake │
│ {Finished} MAC over it all │
│◀──────── 3. proof of identity ─────────────│
│ │
│ {Finished} │
│ {POST /v1/transfers ...} │
│───────── 4. app data, after 1 RTT ────────▶│
Three details matter for security:
- The key share comes in the first message. The client guesses which key exchange the server will accept and sends its half right away. That's what saves the round trip.
- The certificate travels encrypted. In TLS 1.2 a passive watcher saw it; in 1.3 they don't (though anyone can still connect and fetch it themselves).
- CertificateVerify signs the whole handshake transcript. If an attacker in the middle altered any hello message, for example to strip strong options, the signature and the Finished MACs won't match and the connection fails.
TLS 1.3 also removed a lot: static RSA key exchange, CBC-mode and RC4 ciphers, compression,
and renegotiation. Only five cipher suites remain, all AEAD, for example TLS_AES_128_GCM_SHA256.
3. ECDHE and forward secrecy
The key share is half of an ECDHE (Elliptic Curve Diffie-Hellman Ephemeral) exchange: each side makes a brand-new key pair for this one connection, swaps public halves, and combines them into the same secret. Someone watching sees both public halves but can't compute the secret. (The math gets its own lesson, L16.)
"Ephemeral" is the important part. Compare what happens if the server's long-term private key is stolen a year later:
TLS 1.2 with static RSA key exchange
client encrypts the session secret with the server's RSA key
attacker recorded last year's traffic
+ steals the server key today ──▶ ALL old sessions decrypt
TLS 1.3 (always ephemeral (EC)DHE on a full handshake)
fresh key pair per connection, deleted afterwards
the certificate key only SIGNS; it never protected the secret
attacker recorded last year's traffic
+ steals the server key today ──▶ old sessions stay sealed
That property is forward secrecy: compromising a long-term key doesn't expose past sessions.
For financial data that must stay confidential for years, it's essential. Newer clients also
send a hybrid post-quantum key share (X25519MLKEM768), which combines classic X25519 with
ML-KEM to resist "record now, decrypt later with a future quantum computer."
4. What TLS does not protect
TLS protects the pipe, not the things at either end, and not everything about the pipe:
What someone on the network path sees for one API call
VISIBLE HIDDEN by TLS
─────────────────────────────── ───────────────────────────────
client IP and server IP URL path and query string
the DNS lookup (L08) all headers, incl. Authorization
SNI: "api.example.com" cookies, request and response
TLS version, packet sizes, bodies
timing, how much was sent the certificate (TLS 1.3 only)
Encrypted Client Hello (ECH), now standardized as RFC 9849, can hide the SNI, but support is still patchy. Beyond metadata, TLS does nothing for:
- Data at rest. Once the service writes the record to MongoDB or S3, TLS is out of the picture.
- The endpoints. Malware on the phone or a compromised pod sees plaintext. Heartbleed (2014) was a bug in the TLS library that leaked server memory; the protocol was fine.
- Authorization. A customer with a valid token who asks for someone else's account gets it, over perfectly good TLS. That's L40's BOLA.
- Who the server is, beyond its name. TLS proves you reached the holder of a valid certificate for that name. A look-alike domain with its own valid certificate passes (L06's padlock point).
5. Where TLS ends in a typical stack
TLS protects one hop: a single connection between two machines. Whoever holds the private key terminates it, meaning they decrypt and see plaintext. In a typical setup that happens more than once:
[ mobile app ]
║ TLS 1.3, ALB's certificate for api.example.com
▼
( AWS ALB ) ends #1: ALB decrypts, reads HTTP
│ hop A: HTTP or new TLS? (target group protocol)
▼
( Kong pod, EKS ) ends #2 if hop A is HTTPS
│ hop B: HTTP or mTLS? (Kong service protocol)
▼
( policy-svc pod ) ends #3 if hop B is HTTPS
│ hop C: TLS to MongoDB? (tls=true in the URI)
▼
═══ MongoDB ═══
Every termination point sees the whole request, token included, and every hop marked with a
│ could be plaintext. The first deep dive covers how to decide what each hop should be.
🧪 Worked example: reading a real handshake
You can watch the messages from the diagram fly by. With OpenSSL 3.x or later, -msg prints each
handshake message (>>> sent, <<< received). Output trimmed, record-header lines removed:
$ openssl s_client -connect example.com:443 \
-servername example.com -msg </dev/null 2>&1 \
| grep -E '^(<<<|>>>)' | grep -v RecordHeader
>>> TLS 1.3, Handshake [length 0621], ClientHello (1)
<<< TLS 1.3, Handshake [length 007a], ServerHello (2)
<<< TLS 1.3, ChangeCipherSpec [length 0001] (3)
<<< TLS 1.3, Handshake [length 0006], EncryptedExtensions
<<< TLS 1.3, Handshake [length 1950], Certificate
<<< TLS 1.3, Handshake [length 0050], CertificateVerify
<<< TLS 1.3, Handshake [length 0034], Finished (4)
>>> TLS 1.3, ChangeCipherSpec [length 0001]
>>> TLS 1.3, Handshake [length 0034], Finished
>>> TLS 1.3, Alert [length 0002], warning close_notify (5)
The ClientHello is 0x621 = 1,569 bytes, large because of a ~1.2 KB post-quantum key share. Swap
-msgfor-traceto see the extensions inside it:extension_type=supported_versions(43), length=5 TLS 1.3 (772) TLS 1.2 (771) extension_type=key_share(51), length=1258 NamedGroup: X25519MLKEM768 (4588) NamedGroup: ecdh_x25519 (29) extension_type=server_name(0), length=16The ServerHello's key_share said
ecdh_x25519: this server picked the classic share over the hybrid one. (Against a localopenssl s_server, where both sides support it,-briefprintsNegotiated TLS1.3 group: X25519MLKEM768.) After this line, everything is encrypted.ChangeCipherSpecdoes nothing in TLS 1.3. It's a dummy message sent so that old firewalls that expect TLS 1.2 don't drop the connection (RFC 8446 "middlebox compatibility mode"). For the same reason,-traceshowsclient_version=0x303 (TLS 1.2); the real version lives insupported_versions.Certificate, CertificateVerify, and Finished: the server proved who it is. One round trip.
close_notifyis the polite "I'm hanging up," sent because stdin hit end of file.
🛠 Try it (optional, ~5 min)
First check which tool you have. On a Mac where Homebrew's OpenSSL is first in PATH, the system
copy at /usr/bin/openssl is still LibreSSL:
$ openssl version
OpenSSL 4.1.0 23 Oct 2026 (Library: OpenSSL 4.1.0 23 Oct 2026)
$ /usr/bin/openssl version
LibreSSL 3.3.6
Then connect (read-only; it just opens a connection to a public test site):
$ echo | openssl s_client -connect example.com:443 \
-servername example.com -brief
CONNECTION ESTABLISHED
Protocol version: TLSv1.3
Ciphersuite: TLS_AES_256_GCM_SHA384
Peer certificate: CN=example.com
Signature type: ecdsa_secp256r1_sha256
Verification: OK
Peer Temp Key: X25519, 253 bits
What to look for:
- Protocol and Ciphersuite: TLS 1.3 with an AEAD suite.
Peer Temp Keyis the ephemeral ECDHE key: forward secrecy in action. - Add
-tls1_2and compare: the suite becomesECDHE-ECDSA-AES256-GCM-SHA384, with the key exchange and signature named inside it, because TLS 1.2 bundled them into the suite name. - Who issued the certificate? Run it without
-brief, then| grep -E '^(subject|issuer)='. On a home network you'll see a public certificate authority. On a corporate laptop you may see something likeissuer=... CN=Example Corp Inspection CA. That means the company's TLS inspection proxy terminated your connection and re-encrypted it (L06). Your TLS ended at the proxy, not at example.com.
macOS note: LibreSSL 3.3.6 has no -brief option, prints New, TLSv1/SSLv3, Cipher is AEAD-AES256-GCM-SHA384 even on a TLS 1.3 connection (check the Protocol : TLSv1.3 line under
SSL-Session), sends no post-quantum key share, and uses a different trust store from Keychain,
so it can report verify error:num=19 behind a corporate proxy. brew install openssl gives you
the modern tool.
🧠 Key takeaways
- TLS gives data in transit confidentiality, integrity (AEAD tags), and server authenticity.
- TLS 1.3 finishes in one round trip because the client sends its key share in the first message.
- Ephemeral ECDHE gives forward secrecy: a stolen server key can't decrypt recorded sessions.
- TLS doesn't protect data at rest, compromised endpoints, metadata like SNI and IPs, or authorization decisions.
- TLS protects one hop. Every load balancer, gateway, or proxy that terminates it sees plaintext.
📖 Words to know
| Term | Meaning |
|---|---|
| TLS | The protocol that encrypts and authenticates a connection; the "S" in HTTPS |
| Handshake | The opening exchange where both sides agree on algorithms, create keys, and the server proves its identity |
| 1-RTT | A handshake that completes in one network round trip (TLS 1.3) |
| Cipher suite | The named set of algorithms a connection uses, e.g. TLS_AES_128_GCM_SHA256 |
| SNI | Server Name Indication: the hostname the client sends, in plaintext, so the server can pick a certificate |
| ECDHE | Elliptic Curve Diffie-Hellman Ephemeral: a fresh key agreement for every connection |
| Forward secrecy | Stealing a long-term key later doesn't expose past sessions |
| CertificateVerify | The server's signature over the handshake, proving it holds the certificate's private key |
| TLS termination | The point where a TLS connection is decrypted, e.g. a load balancer or gateway |
| mTLS | Mutual TLS: the client also presents a certificate (L11) |
| ECH | Encrypted Client Hello: an extension that hides SNI and other ClientHello details |
⏭ Next time
L11: certificates and PKI. What's actually inside the certificate the server just sent, who vouches for it, and how your client decides to trust it.
Where your TLS ends: termination, re-encryption and the plaintext hop
The question behind "is it encrypted?"
An auditor asks, "Is customer data encrypted in transit?" The honest answer is per hop. A TLS connection runs between exactly two endpoints; anything that needs to read HTTP (route on a path, check a JWT, add a header) has to terminate it. Your job is to know every termination point and decide what happens on the hop after it.
Three patterns
A. TERMINATE, then plaintext
client ══TLS══▶ ALB ──HTTP──▶ Kong ──HTTP──▶ service
simple; anyone inside the VPC who can sniff or reach a hop
sees tokens and bodies
B. TERMINATE and RE-ENCRYPT
client ══TLS══▶ ALB ══TLS══▶ Kong ══mTLS══▶ service
each hop is a separate TLS connection with its own keys;
each box in the middle still sees plaintext in memory
C. PASSTHROUGH
client ══════════TLS══════════▶ Kong (via NLB, TCP)
the NLB forwards bytes it can't read; TLS ends at Kong
Pattern C uses an NLB TCP listener: it can't see HTTP, so it can't do path routing or a WAF, but your certificate's private key never leaves the cluster. An NLB can also run a TLS listener and terminate like an ALB. Pattern B is where most regulated environments end up, often with a service mesh such as Istio or Linkerd handling pod-to-pod mTLS automatically (more on pod-to-pod security in L51).
Re-encryption isn't the same as verification
Re-encrypting a hop gives confidentiality. It only gives authenticity if the client side of that hop checks the certificate. Two defaults are worth knowing:
- ALB to targets. AWS's docs say plainly that the ALB "does not validate these certificates," so targets can use self-signed or expired ones. AWS argues that VPC traffic is authenticated at the packet level. That's reasonable inside one VPC, but it means hop A gives you encryption, not proof that you reached the right Kong.
- Kong to upstream services. Kong only checks the upstream's certificate if verification is
on. Kong Gateway 3.14 turned on global enforcement (
tls_certificate_verify) by default; in 3.13 and earlier it was off, and a service'stls_verifyfell back to NGINX's default,proxy_ssl_verify off. Check your version rather than assuming.
🧪 Worked example: hardening hop B
A Kong service in decK format that routes to policy-service over plain HTTP (made-up names):
# before: hop B is plaintext inside the cluster
services:
- name: policy-service
url: http://policy-service.policy.svc:8080
After: TLS to the upstream, with the certificate checked against an internal CA. Kong also sends its own client certificate so the service can tell it's really Kong (mTLS):
services:
- name: policy-service
url: https://policy-service.policy.svc:8443
tls_verify: true # check the upstream's cert
tls_verify_depth: 2
ca_certificates: # trust only the internal CA
- 3f1c9a2e-0d4b-4c71-9b8e-5a2f6d7e8c10
client_certificate: # Kong's own cert, for mTLS
id: 9a7b6c5d-4e3f-4a2b-8c1d-0e9f8a7b6c5d
Then confirm from a debug pod in the same namespace (your own dev cluster only):
$ openssl s_client -connect policy-service.policy.svc:8443 \
-servername policy-service.policy.svc -brief </dev/null
CONNECTION ESTABLISHED
Protocol version: TLSv1.3
...
Verification error: unable to get local issuer certificate
That verification error is expected: your debug pod doesn't trust the internal CA. Add
-CAfile internal-ca.pem and it should say Verification: OK. If the service accepts the
connection without a client certificate, mTLS isn't actually enforced. (The field names are
from Kong's Gateway Service entity, tls_verify, tls_verify_depth, ca_certificates, and
client_certificate; the exact YAML shape depends on whether you use decK, the Admin API, or
Kong's Kubernetes annotations, so check your version's docs.)
Decide each hop on purpose
For every arrow in your diagram, write down:
| Hop | Who terminates? | Encrypted? | Peer verified? | Why this is OK |
|---|---|---|---|---|
| app → ALB | ALB | TLS 1.3 | Server cert, by the app | Internet |
| ALB → Kong | Kong | TLS | No (ALB doesn't verify) | Same VPC; SG allows only the ALB |
| Kong → policy-svc | policy-svc | mTLS | Both sides | Any pod could reach it otherwise |
| policy-svc → Mongo | MongoDB | TLS | Server cert | Data tier boundary |
Also list the boxes that see plaintext: ALB, Kong, any TLS inspection proxy, the service. Each one
can log a request body or an Authorization header by accident, which is exactly how tokens end
up in log files (L60).
🛠 Try it (~5 min)
Draw the request path for one API you own, from the client to the database. Mark each termination point with an ✕ and each hop as TLS, mTLS, or plaintext. Which hop are you least sure about? That's where to look in the Helm chart, the gateway config, or the target group settings.
🧠 Extra takeaways
- TLS is per hop. "Encrypted in transit" means nothing until you name the hops.
- Re-encryption gives confidentiality; verification of the peer's certificate gives authenticity.
- ALBs don't verify target certificates. Kong's upstream verification default depends on version.
- Every termination point sees plaintext, so it's also a place secrets can leak into logs.
| Term | Meaning |
|---|---|
| Hop | One TLS connection between two machines on a longer request path |
| Re-encryption | Terminating TLS, then opening a new TLS connection to the next hop |
| TLS passthrough | Forwarding encrypted bytes without terminating them, e.g. an NLB TCP listener |
| Plaintext hop | A hop where HTTP travels unencrypted, usually inside a "trusted" network |
Downgrades, 0-RTT replay and other TLS sharp edges
Downgrade attacks
If a client and server both support a strong option and a weak one, an attacker in the middle can try to make them settle on the weak one. That's a downgrade attack. The 2014–2015 era was full of them: POODLE pushed connections down to SSL 3.0, and FREAK and Logjam tricked servers into deliberately weakened "export" key exchanges.
client ─"1.3, 1.2, 1.0"─▶ attacker ─"1.0 only"─▶ server
client ◀──── "ok, 1.0" ──── attacker ◀── "ok, 1.0" ── server
weak connection, which the attacker can then try to break
TLS 1.3 defends in two ways. First, CertificateVerify and Finished cover the whole transcript,
so an edited ClientHello breaks the handshake. Second, a TLS 1.3 server that's forced to answer
with 1.2 or lower puts a fixed marker, the ASCII bytes DOWNGRD plus one byte, at the end of its
ServerHello random value. A 1.3-capable client that sees it aborts.
Those defenses only help if the weak versions are still possible. TLS 1.0 and 1.1 were formally deprecated by RFC 8996 in 2021. The real fix is to stop accepting them.
🧪 Worked example: the ALB default you didn't choose
On AWS, an ALB HTTPS listener created in the console gets the default security policy
ELBSecurityPolicy-TLS13-1-2-Res-PQ-2025-09. One created with the CLI, CloudFormation, or the
CDK without a policy gets ELBSecurityPolicy-2016-08, which supports TLS 1.0, 1.1, and 1.2,
and not TLS 1.3. (These are AWS's documented defaults as of this writing.)
$ aws elbv2 describe-listeners --load-balancer-arn "$ALB" \
--query 'Listeners[].[Port,Protocol,SslPolicy]' --output text
443 HTTPS ELBSecurityPolicy-2016-08 <- accepts TLS 1.0
$ aws elbv2 modify-listener --listener-arn "$LISTENER" \
--ssl-policy ELBSecurityPolicy-TLS13-1-2-Res-PQ-2025-09
Better still, set SslPolicy explicitly in the CloudFormation or Terraform template, so the next
rebuild doesn't put the old default back. Before tightening, enable ALB connection logs and check
the TLS version field to see which clients still use old versions.
0-RTT: fast, and replayable
TLS 1.3 has an optional shortcut for returning clients. A client that connected before can send early data (0-RTT) in its very first message, using a key from the previous session. It saves a round trip, but RFC 8446 is blunt about the cost: "There are no guarantees of non-replay between connections." An attacker who captures that first flight can resend it:
1. client ── ClientHello + early data: POST /v1/transfers ──▶ edge
2. attacker copies those bytes off the wire (can't read them)
3. attacker ── same bytes again ─────────────────────────────▶ edge
4. edge decrypts both: the transfer may run twice
The attacker doesn't need to decrypt anything; they replay ciphertext. So the rules are:
allow 0-RTT only for safe, idempotent requests like GET, and require idempotency keys on any
money-moving POST. RFC 8470 defines an Early-Data: 1 header that a CDN or proxy adds, and a
425 Too Early status that tells the client to retry after the full handshake. AWS documents
that ALBs don't implement 0-RTT at all; CDNs and some proxies offer it as a setting.
The sharp edge you'll actually see: turning verification off
The most common TLS failure in real code isn't a protocol attack. It's a developer disabling certificate checks to get past an error:
# DON'T: encrypted, but to anyone at all, including an attacker
requests.get("https://partner.example.com/v1/rates", verify=False)
# DO: trust the specific internal CA bundle
requests.get("https://partner.example.com/v1/rates",
verify="/etc/ssl/certs/internal-ca.pem")
The Java equivalent is a "trust all" TrustManager or a HostnameVerifier that returns true;
in Go it's InsecureSkipVerify: true. Each keeps confidentiality against passive eavesdroppers
but throws away server authenticity, so an active attacker can sit in the middle. Search your
repos for these strings; they belong in tests at most, never in production config.
Two more to know
- Record now, decrypt later. Forward secrecy protects against a stolen server key, not against a future quantum computer breaking X25519 itself. That's why hybrid ML-KEM key shares are rolling out now, for data like policy and account records that must stay secret for decades.
- Implementation bugs. Heartbleed (CVE-2014-0160) let anyone read chunks of server memory, including private keys, from a perfectly configured TLS 1.2 server. Patch your TLS libraries, base images, and gateways like any other dependency (L02).
🛠 Try it (~5 min)
See downgrade protection from the client side. Ask the public test site for an old version only:
$ echo | openssl s_client -connect example.com:443 \
-servername example.com -tls1_1 -brief
...:error:0A0000BF:SSL routines:tls_setup_handshake:
no protocols available
That error comes from your own client: OpenSSL 3.x and later won't send TLS 1.1 at its default security level. Lower the level to see what the server says:
$ echo | openssl s_client -connect example.com:443 \
-servername example.com -tls1_1 \
-cipher 'DEFAULT:@SECLEVEL=0' -brief
...:error:0A00042E:SSL routines:ssl3_read_bytes:
tlsv1 alert protocol version:...:SSL alert number 70
Alert 70, protocol_version: the server refused the old version. No connection either way, which
is what you want. Then run grep -rnE 'verify=False|InsecureSkipVerify|TrustAll' . in a repo you
own.
🧠 Extra takeaways
- Downgrade attacks force weak options; TLS 1.3 signs the transcript and marks forced downgrades.
- Disable TLS 1.0/1.1 explicitly; don't trust tool defaults (an ALB from the CLI accepts TLS 1.0).
- 0-RTT early data can be replayed; use it only for idempotent requests.
verify=Falsekeeps encryption but loses authenticity, so it invites man-in-the-middle attacks.
| Term | Meaning |
|---|---|
| Downgrade attack | Forcing two parties to negotiate a weaker version or algorithm than both support |
| Downgrade sentinel | The DOWNGRD marker a TLS 1.3 server puts in its random value when it negotiates an older version |
| 0-RTT / early data | Application data sent in a resumed TLS 1.3 connection's first flight, before the handshake completes |
| Replay attack | Resending captured messages so the receiver acts on them again |
425 Too Early |
HTTP status asking a client to retry a request it sent as early data |
The quiz for this lesson and the Tests section need JavaScript.