CyberNugget

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)
  1. The ClientHello is 0x621 = 1,569 bytes, large because of a ~1.2 KB post-quantum key share. Swap -msg for -trace to 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=16
    
  2. The ServerHello's key_share said ecdh_x25519: this server picked the classic share over the hybrid one. (Against a local openssl s_server, where both sides support it, -brief prints Negotiated TLS1.3 group: X25519MLKEM768.) After this line, everything is encrypted.

  3. ChangeCipherSpec does 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, -trace shows client_version=0x303 (TLS 1.2); the real version lives in supported_versions.

  4. Certificate, CertificateVerify, and Finished: the server proved who it is. One round trip.

  5. close_notify is 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 Key is the ephemeral ECDHE key: forward secrecy in action.
  • Add -tls1_2 and compare: the suite becomes ECDHE-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 like issuer=... 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.

Deep dive 1

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's tls_verify fell 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
Deep dive 2

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=False keeps 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.