Skip to main content

On This Page

TLS: How Your Browser Keeps Secrets (And Why It's Harder Than You Think)

14 min read
Share

TL;DR

TLS is a layered protocol: a handshake layer that negotiates keys, and a record layer that encrypts data using those keys. TLS 1.3 cut a decade of legacy cruft, reduced the handshake to 1 round trip (vs 2 in TLS 1.2), and made perfect forward secrecy mandatory. Most developers treat it as magic that happens before the first HTTP byte. It isn’t magic—it’s a well-specified cryptographic state machine, and knowing how it works will make you better at debugging, configuration, and threat modeling.


The Problem TLS Solves (Actually)

Forget “it encrypts your traffic.” That’s the outcome, not the problem. The actual problem is:

  1. Authentication — How do you know you’re talking to GitHub and not some router in a data center on the way?
  2. Confidentiality — How do you encrypt data so that intercepted packets are useless?
  3. Integrity — How do you know a packet wasn’t tampered with in transit?
  4. Forward Secrecy — If your long-term private key gets leaked next year, can an attacker decrypt traffic captured today?

TLS solves all four, in roughly that order of importance. A lot of developers think encryption is the main point. Authentication is actually more critical—encrypt traffic to a MITM and you’ve accomplished nothing.


The Cryptographic Primitives (What TLS Actually Uses)

Before the handshake makes sense, you need to know what tools it’s assembling.

ECDH — Key Agreement Without Transmitting a Key

Elliptic Curve Diffie-Hellman lets two parties independently derive the same secret without ever sending that secret over the wire. Each side generates an ephemeral keypair, exchanges public keys, and runs math on (their private key × your public key). Both sides land on the same point on the elliptic curve—that point becomes the shared secret.

The magic: a passive eavesdropper sees both public keys, but deriving the shared secret from them requires solving the elliptic curve discrete logarithm problem. For X25519 (the curve TLS 1.3 prefers), this is computationally infeasible.

Client keypair:  (priv_c, pub_c)
Server keypair:  (priv_s, pub_s)

Client computes: priv_c * pub_s = shared_secret
Server computes: priv_s * pub_c = shared_secret
                ↑ same result, by the math of elliptic curves

HKDF — Turning Raw Shared Secrets Into Usable Keys

Raw DH output is not a key. You run it through HKDF (HMAC-based Key Derivation Function), a two-step function: Extract (mix entropy sources into a pseudo-random key), then Expand (derive multiple output keys of any length).

TLS 1.3 uses HKDF to derive four distinct symmetric keys from the shared secret: client write key, server write key, client write IV, server write IV. Separate keys in each direction. No reuse.

AEAD — Encryption With Built-In Integrity

TLS 1.3 only allows AEAD ciphers (Authenticated Encryption with Associated Data). The two you’ll encounter:

  • AES-128-GCM — AES in Galois/Counter Mode. Fast on hardware with AES-NI extensions. Widely deployed.
  • ChaCha20-Poly1305 — Designed for software performance when AES hardware isn’t available. Common on mobile/ARM.

AEAD takes plaintext and produces ciphertext plus an authentication tag. Decryption fails outright if the tag doesn’t verify. You can’t tamper with ciphertext silently—any modification changes the tag, and the receiver knows immediately.


The TLS 1.3 Handshake

This is where the protocol negotiates identity and establishes keys. TLS 1.3 does it in one round trip. Not two. Not three. One.

TLS 1.3 Handshake Flow

Step 1: ClientHello

The client sends:

  • Supported cipher suites — e.g., TLS_AES_128_GCM_SHA256
  • Supported key share groups — e.g., x25519, secp256r1
  • Key shares — The client doesn’t wait. It pre-generates ephemeral key pairs for its preferred groups and sends the public keys immediately
  • Session ticket (if resuming) — For 0-RTT, more on that below
  • Random nonce — 32 bytes of randomness, used in key derivation
  • SNI — Server Name Indication: which hostname you’re connecting to (still plaintext in TLS 1.3 unless ECH is enabled)

The reason TLS 1.3 is 1-RTT: the client speculatively sends a key share. If the server picks the same group, it can respond immediately. If not (a “HelloRetryRequest”), you pay an extra round trip.

Step 2: ServerHello + Encrypted Extensions + Certificate + CertVerify + Finished

The server’s response is a burst of messages, most of which are already encrypted:

ServerHello (plaintext):

  • Which cipher suite was selected
  • The server’s key share (its ephemeral public key)
  • Session ID / key for resumption

At this point, both sides have each other’s ephemeral public keys. They derive the handshake traffic secret immediately. Everything from here is encrypted.

EncryptedExtensions (encrypted):

  • ALPN (protocol negotiation: HTTP/2 vs HTTP/1.1)
  • Server’s max fragment size
  • Other TLS extensions that don’t affect crypto

Certificate (encrypted):

  • The server’s X.509 certificate chain
  • Goes all the way up to an intermediate CA, but typically not including the root (client has that in its trust store)

CertificateVerify (encrypted):

  • A signature over a transcript hash of everything sent so far
  • Proves the server has the private key matching the certificate’s public key
  • This is the authentication step

Finished (encrypted):

  • HMAC over the handshake transcript
  • Proves the handshake wasn’t tampered with

Step 3: Client Finished

Client verifies the certificate chain (more on this below), verifies the CertificateVerify signature, verifies the Finished MAC, then sends its own Finished message.

Handshake complete. Application data starts flowing—encrypted with the application traffic secret, which is derived separately from the handshake traffic secret.

Why This Is Beautiful

The entire key exchange used ephemeral keys. The certificate proved server identity, but the session key came from the ephemeral ECDH exchange. The server’s long-term private key was only used to sign the CertificateVerify—not to encrypt anything. This is the foundation of perfect forward secrecy.


The Record Protocol

Once the handshake is done, every byte travels in TLS records. The record layer is straightforward but the details matter.

TLS Record Protocol

Each record has a 5-byte header:

1 byte:  ContentType  (application_data = 23)
2 bytes: Legacy version (always 0x0303 in TLS 1.3, even though it's TLS 1.3)
2 bytes: Length of the following payload
N bytes: Encrypted payload (AEAD ciphertext + auth tag)

The Legacy version field is stuck at 0x0303 (TLS 1.2) for middlebox compatibility. TLS 1.3 negotiation happens in extensions, not in this field. This is why TLS version detection from network captures needs to look at the ClientHello extensions, not the record header version.

What’s Inside the Encrypted Payload?

[plaintext content]
[content type byte]  ← actual content type, hidden inside encryption
[zero padding]       ← optional, for traffic analysis resistance

That’s right—the actual ContentType is encrypted too. The outer header always says application_data (23). The real type is inside the ciphertext. This prevents passive observers from distinguishing handshake messages from application data.

Sequence Numbers and Nonces

AEAD requires a unique nonce per encryption. Reusing a nonce with the same key is catastrophic (it breaks confidentiality and sometimes leaks the key). TLS 1.3 constructs nonces by XOR-ing an implicit sequence number with the write IV derived during the handshake. The sequence number increments per record and is never transmitted—both sides track it in sync.

If a record arrives out of order or is dropped, the receiver’s sequence number diverges from the sender’s, the AEAD authentication fails, and the connection is terminated. There are no retries at the TLS level—that’s TCP’s job.


Certificates and the PKI

Certificates are how TLS binds a public key to a name. The structure follows X.509v3 and is roughly:

Certificate:
  Subject: CN=api.example.com
  Issuer:  CN=Let's Encrypt E5
  Validity: 2026-04-01 to 2026-07-01
  Public Key: (ECDSA P-256 key)
  Extensions:
    Subject Alternative Names: api.example.com, www.example.com
    Key Usage: Digital Signature
    Extended Key Usage: TLS Web Server Authentication
  Signature: (signed by Let's Encrypt E5's private key)

The critical insight: the certificate is not self-certifying. Its validity depends on the issuer’s signature. And the issuer’s validity depends on their issuer’s signature. You follow the chain up to a Root CA that’s in your OS or browser trust store.

Certificate Chain of Trust

Chain Validation (What The Client Actually Does)

  1. Build the chain: leaf → intermediate(s) → root
  2. Verify each signature: does intermediate’s signature on leaf cert verify with intermediate’s public key? Does root’s signature on intermediate verify with root’s key?
  3. Check the root is trusted (it’s in your OS/browser trust store)
  4. Check dates: is notBefore <= now <= notAfter?
  5. Check revocation: OCSP or CRL — is any cert in the chain revoked?
  6. Check the Subject Alternative Names: does the hostname you’re connecting to appear in the cert’s SANs?

If any step fails, the connection is terminated. The browser shows an error. The user clicks “Advanced → Proceed anyway” because they just want to read an article.

Certificate Transparency

Since 2018, major browsers require that certificates be logged to public Certificate Transparency logs before they’re accepted. Each cert gets an SCT (Signed Certificate Timestamp) from a log server. The server presents this SCT in the TLS handshake.

This means you can query public logs (crt.sh) to see every certificate ever issued for a domain. Great for monitoring unauthorized certificates. Also great for attackers doing recon.

curl "https://crt.sh/?q=example.com&output=json" | jq '.[].name_value' | sort -u

Run that on your own domains. You might find certificates you didn’t know existed.


Perfect Forward Secrecy

This concept is critical and often hand-waved in explanations.

Old-school RSA key exchange (pre-TLS 1.3): The client would encrypt a random “pre-master secret” with the server’s public RSA key. The server decrypted it with its private RSA key. If an attacker recorded encrypted traffic and later obtained the private key (via breach, subpoena, or NSL), they could decrypt all historical sessions.

ECDHE (TLS 1.3): The session key comes from ephemeral Diffie-Hellman. The server’s certificate/private key only authenticated the handshake—it didn’t encrypt the session key. The ephemeral private keys are discarded after the session. Future compromise of the long-term key reveals nothing about past sessions.

Perfect Forward Secrecy

TLS 1.3 made ECDHE mandatory. Not optional. Not configurable. There are no TLS 1.3 cipher suites without it.

This matters for threat models. Nation-state actors doing “collect now, decrypt later” on TLS 1.2 RSA sessions? TLS 1.3 closes that door.


Key Derivation Schedule

TLS 1.3’s key schedule is the most intricate part of the spec. Here’s the high-level flow:

Early Secret
  ← HKDF-Extract(0x0000...0, PSK or 0x0000...)
  ↓ derives: binder_key, early_traffic_secret (0-RTT)

Handshake Secret
  ← HKDF-Extract(Derive(Early Secret), ECDH output)
  ↓ derives: client_handshake_traffic_secret
             server_handshake_traffic_secret

Master Secret
  ← HKDF-Extract(Derive(Handshake Secret), 0x0000...)
  ↓ derives: client_application_traffic_secret_0
             server_application_traffic_secret_0
             exporter_master_secret
             resumption_master_secret

Each _traffic_secret is then expanded into an actual symmetric key + IV pair via HKDF-Expand-Label. Separate keys for each direction. Separate keys for handshake vs application data. The structure is intentional—compromise of one derived secret doesn’t cascade.


0-RTT Resumption (And Why It’s Complicated)

TLS 1.3 introduced 0-RTT data: the client can send application data in the first flight, before the handshake completes. This is great for latency. It’s also a footgun.

The mechanism: after a session completes, the server issues a New Session Ticket containing an encrypted resumption secret. On reconnect, the client can use this as a Pre-Shared Key and include application data immediately.

The problem: 0-RTT data has no forward secrecy. If the ticket key is compromised, 0-RTT data can be decrypted. More critically, 0-RTT has no replay protection at the TLS layer. If an attacker copies the first flight and replays it to a different server instance, that server will accept and process it.

Mitigations exist (single-use tickets, server-side state, application-layer idempotency), but they require explicit handling. If you’re enabling 0-RTT in nginx/HAProxy/your Go server, read the RFC section on anti-replay first.


TLS 1.2 vs 1.3: What Was Actually Removed

TLS 1.3 is not “TLS 1.2 with a few tweaks.” It removed a significant amount of legacy machinery:

FeatureTLS 1.2TLS 1.3
RSA key exchangeAllowedRemoved
Static DHAllowedRemoved
RC4Allowed (deprecated)Removed
3DESAllowed (deprecated)Removed
CBC mode ciphersAllowedRemoved
SHA-1 in cert signaturesAllowedRemoved
CompressionAllowed (causes CRIME)Removed
RenegotiationSupportedRemoved
Session resumption (IDs)SupportedReplaced by tickets
Handshake round trips21
Encrypted cert in handshakeNoYes

The removals weren’t arbitrary. RC4 was cryptographically broken. CBC mode with MAC-then-Encrypt produced BEAST and Lucky13. CRIME exploited TLS compression. RSA key exchange had no forward secrecy. Each removal closes an attack class.


Common Attacks (Historical and Relevant)

BEAST (2011) — Exploited CBC mode in TLS 1.0. IV was predictable. Combined with a browser vulnerability to inject chosen plaintext. Mitigated by upgrading to TLS 1.1+ and RC4 (before RC4 was broken). Actually mitigated in TLS 1.3 by removing CBC.

CRIME (2012) — Exploited TLS compression. If an attacker can inject into plaintext and observe compressed ciphertext length, they can brute-force secret values. TLS 1.3 removed compression.

Heartbleed (2014) — Not a TLS protocol flaw. An OpenSSL implementation bug: the Heartbeat extension didn’t validate payload length, allowing arbitrary memory reads from the server process. Leaked private keys, session tokens, passwords. Affected OpenSSL 1.0.1 through 1.0.1f.

FREAK (2015) / Logjam (2015) — Downgrade attacks forcing use of export-grade (512-bit) RSA/DH keys, a US export restriction from the 90s that somehow survived in implementations. Cryptographically broken in hours. TLS 1.3 removed support entirely.

POODLE (2014/2015) — SSL 3.0 padding oracle. Extended to TLS 1.0 CBC. Reason CBC got deprecated. TLS 1.3 removes it.

Raccoon Attack (2020) — Timing side-channel in TLS 1.2 DH. Applicable only when DH secret has leading zeros. Not applicable to ECDH or TLS 1.3.

The pattern: most TLS attacks target the legacy cruft. TLS 1.3 is cleaner partly because it had the history to learn from.


Practical Implications

Server configuration:

# Modern nginx TLS config
ssl_protocols TLSv1.3;  # Drop 1.2 if you can
ssl_ciphers TLS_AES_256_GCM_SHA384:TLS_CHACHA20_POLY1305_SHA256:TLS_AES_128_GCM_SHA256;
ssl_prefer_server_ciphers off;  # In TLS 1.3 this is irrelevant, but still
ssl_session_tickets off;  # Disable for PFS with session resumption tickets
ssl_stapling on;         # OCSP stapling: faster cert validation
ssl_stapling_verify on;

Testing:

# Check what a server actually negotiates
openssl s_client -connect example.com:443 -tls1_3 2>&1 | grep -E "Protocol|Cipher|Verify"

# Full dump of negotiated parameters
curl -vv https://example.com 2>&1 | grep -E "TLS|SSL|subject|issuer"

# testssl.sh for exhaustive testing
docker run --rm drwetter/testssl.sh example.com

Debugging certificate issues:

# Show full cert chain
openssl s_client -connect example.com:443 -showcerts 2>/dev/null | \
  openssl x509 -noout -text | grep -A2 "Subject\|Issuer\|Not"

# Check OCSP response
openssl s_client -connect example.com:443 -status 2>&1 | grep -A10 "OCSP"

# Verify cert chain manually
openssl verify -CAfile /etc/ssl/certs/ca-certificates.crt cert.pem

The Bits You Probably Don’t Think About

SNI is still cleartext. Your ISP and any on-path observer can see which hostname you’re connecting to, even with TLS 1.3. ECH (Encrypted Client Hello, formerly ESNI) encrypts the SNI, but requires the server to publish its public key in DNS, and needs HTTPS DNS records. Cloudflare supports it. Mainstream deployments are still limited.

Certificate lifetimes are shrinking. Let’s Encrypt issues 90-day certs. Chrome’s root store policy is pushing for 90-day maximum. Eventually, manual certificate management will just stop working—automation (ACME/certbot) is the only path.

Mutual TLS (mTLS) flips the auth model. The server also requests a certificate from the client. Common in service meshes (Istio), zero-trust networking, and API authentication. The handshake adds a client Certificate + CertificateVerify flight.

QUIC uses TLS 1.3 directly. HTTP/3 runs over QUIC, which embeds TLS 1.3 handshake messages into its own packet format. The TLS record layer is not used—QUIC has its own framing. But the same crypto handshake, key schedule, and AEAD ciphers apply.

Continue reading

Next article

AI Agents from Scratch Part 6: Complete Agent & Best Practices (Research Report Generator)

Related Content