How TLS Works

"TLS is not just encryption. It is authentication, integrity, and confidentiality — or it is nothing." — Eric Rescorla, author of the TLS 1.3 specification


The Protocol That Secures the Internet

Every time you open a browser, check your email, push to GitHub, or call an API — TLS is protecting that connection. It's the single most important security protocol on the internet. Over 95% of web traffic is now encrypted with TLS. And most developers have never looked inside it.

TLS does far more than encrypt the connection. It provides three things simultaneously:

  1. Authentication — The server (and optionally the client) proves its identity via certificates. You know you're talking to the real google.com, not an impersonator.
  2. Confidentiality — The data is encrypted (AES-GCM or ChaCha20-Poly1305). Eavesdroppers see ciphertext.
  3. Integrity — Every record includes an authentication tag (AEAD). Any tampering is detected and the connection is terminated.

Drop any one of these and TLS fails. Encryption without authentication means you're securely connected to the attacker. Authentication without encryption confirms who you're talking to but lets everyone listen. Integrity without encryption lets you detect tampering but doesn't keep data secret. All three must work together — this is the invariant that the TLS protocol enforces.


TLS 1.2: The Full Handshake

Starting with TLS 1.2 makes sense because it's more explicit about each step. Understanding TLS 1.2 makes TLS 1.3's improvements obvious.

sequenceDiagram
    participant C as Client
    participant S as Server

    rect rgb(52, 73, 94)
        Note over C,S: Round Trip 1

        C->>S: ClientHello<br/>• TLS version: 1.2<br/>• Client Random (32 bytes)<br/>• Session ID<br/>• Cipher suites [ordered list]<br/>• Extensions (SNI, ALPN, etc.)

        S->>C: ServerHello<br/>• TLS version: 1.2<br/>• Server Random (32 bytes)<br/>• Session ID<br/>• Selected cipher suite<br/>• Extensions

        S->>C: Certificate<br/>• Server cert chain<br/>  (server → intermediate → root)

        S->>C: ServerKeyExchange<br/>• ECDHE parameters (curve, pubkey)<br/>• Signature over params<br/>  (using server's RSA/ECDSA key)

        S->>C: ServerHelloDone
    end

    rect rgb(39, 55, 70)
        Note over C,S: Round Trip 2

        Note over C: Verify certificate chain<br/>Verify DH parameter signature<br/>Generate ephemeral DH keypair

        C->>S: ClientKeyExchange<br/>• Client's ECDHE public key

        Note over C,S: Both compute shared secret<br/>Derive session keys via PRF

        C->>S: [ChangeCipherSpec]<br/>"Switching to encrypted mode"

        C->>S: Finished (ENCRYPTED)<br/>• Hash of all handshake messages

        S->>C: [ChangeCipherSpec]

        S->>C: Finished (ENCRYPTED)<br/>• Hash of all handshake messages
    end

    rect rgb(46, 204, 113)
        Note over C,S: Application Data (fully encrypted)
        C->>S: HTTPS Request (encrypted)
        S->>C: HTTPS Response (encrypted)
    end

    Note over C,S: 2 round trips before first application data byte

Every message exists for a reason. This is where the cryptographic concepts from the last four chapters become concrete.


Message 1: ClientHello — "Here's What I Support"

The client announces its capabilities and preferences.

# See a real ClientHello with openssl
$ openssl s_client -connect www.google.com:443 -msg 2>&1 | head -30

# Or capture and analyze with tshark
$ tshark -i en0 -f "host www.google.com and tcp port 443" \
    -Y "tls.handshake.type == 1" -V 2>/dev/null | head -60

Key fields in the ClientHello:

Client Random (32 bytes): A cryptographically random value that feeds into key derivation. Even if an attacker replays a recorded handshake, the different random values produce different session keys, preventing replay attacks.

Cipher Suites: An ordered list of cryptographic algorithm combinations the client supports. The order indicates preference — the first is most preferred.

graph LR
    subgraph CS["Cipher Suite Notation (TLS 1.2)"]
        FULL["TLS_ECDHE_RSA_WITH_AES_128_GCM_SHA256"]
        P["TLS"] --> KE["ECDHE<br/>(Key Exchange)"]
        KE --> AUTH["RSA<br/>(Authentication)"]
        AUTH --> CIPHER["AES_128_GCM<br/>(Encryption)"]
        CIPHER --> HASH["SHA256<br/>(PRF Hash)"]
    end

    subgraph CS13["Cipher Suite Notation (TLS 1.3)"]
        FULL13["TLS_AES_256_GCM_SHA384"]
        P13["TLS"] --> CIPHER13["AES_256_GCM<br/>(Encryption)"]
        CIPHER13 --> HASH13["SHA384<br/>(HKDF Hash)"]
        NOTE13["Key exchange and authentication<br/>are negotiated separately<br/>via extensions, not in the<br/>cipher suite name"]
    end

    style CS fill:#2d3748,color:#e2e8f0
    style CS13 fill:#2d3748,color:#e2e8f0

Server Name Indication (SNI): The hostname the client wants to connect to, sent in plaintext — even in TLS 1.3. The server needs this to select the right certificate (a single IP may host hundreds of domains). This is why network observers can see which websites you visit even over HTTPS.

SNI leaks the destination hostname to any network observer. This is how corporate firewalls, ISPs, and censorship systems determine which HTTPS sites you're visiting without decrypting the traffic. The hostname is right there in the ClientHello, unencrypted.

**Encrypted Client Hello (ECH)** fixes this. The server publishes an ECH public key in its DNS record (HTTPS record type). The client encrypts the SNI and other sensitive ClientHello extensions using this key. The outer ClientHello contains a "cover" SNI (typically the CDN's hostname), while the real SNI is encrypted inside.

Requirements for ECH to work:
- DNS-over-HTTPS (DoH) or DNS-over-TLS (DoT) to prevent the DNS lookup from leaking the hostname
- Server support (Cloudflare supports ECH as of 2024)
- Browser support (Firefox and Chrome have experimental support)

ECH represents the final piece of the metadata privacy puzzle for web browsing.

Messages 2-3: ServerHello and Certificate — "I Choose This, and Here's Who I Am"

The server selects one cipher suite from the client's list, sends its own random value, and presents its certificate chain.

graph TD
    ROOT["Root CA<br/>(self-signed)<br/>Pre-installed in browser/OS<br/>~150 trusted root CAs"]
    ROOT -->|"signs"| INT["Intermediate CA<br/>(signed by Root)<br/>Server includes this in<br/>Certificate message"]
    INT -->|"signs"| LEAF["Server Certificate<br/>CN=example.com<br/>SAN: *.example.com<br/>Contains server's public key<br/>Valid: 90 days"]

    CLIENT_VERIFY["Client verification:<br/>1. Is root in trust store? (pre-installed)<br/>2. Is chain signature valid? (crypto check)<br/>3. Does hostname match CN/SAN?<br/>4. Is certificate not expired?<br/>5. Is certificate not revoked? (OCSP/CRL)<br/>6. Is CT log present? (Chrome requires it)"]

    LEAF --> CLIENT_VERIFY

    style ROOT fill:#38a169,color:#fff
    style LEAF fill:#3182ce,color:#fff
# View a server's certificate chain
$ openssl s_client -connect www.google.com:443 2>/dev/null | \
    grep -E "(depth|subject|issuer)"
depth=2 C = US, O = Google Trust Services LLC, CN = GTS Root R1
depth=1 C = US, O = Google Trust Services, CN = WR2
depth=0 CN = www.google.com

# Check certificate details
$ echo | openssl s_client -connect www.google.com:443 2>/dev/null | \
    openssl x509 -noout -text | \
    grep -E "(Subject:|Issuer:|Not Before|Not After|Public Key Algorithm|DNS:)"

# Check certificate expiration
$ echo | openssl s_client -connect www.google.com:443 2>/dev/null | \
    openssl x509 -noout -dates
notBefore=Jan 15 08:36:54 2026 GMT
notAfter=Apr  9 08:36:53 2026 GMT

# Modern certificates have 90-day lifetimes (Let's Encrypt)
# This limits the window of exposure if a key is compromised

Message 4: ServerKeyExchange — The PFS Magic

The server sends its ephemeral ECDHE public key, along with a digital signature computed with its long-term private key. This is the message that provides Perfect Forward Secrecy.

This message binds the ephemeral key to the authenticated server identity. The signature proves: "This ephemeral DH value was generated by the server whose certificate you just verified." Without this signature, a man-in-the-middle could substitute their own ephemeral key. With it, any substitution would invalidate the signature, and the client would reject the connection.

Messages 8-10: Finished — The Integrity Check

Both sides send Finished messages encrypted with the newly derived session keys. The Finished message contains a hash (specifically, a PRF output) of all handshake messages exchanged so far.

This is the final defense against handshake tampering. If a man-in-the-middle modified any handshake message — changed the cipher suite list, altered the certificate, modified the DH parameters — the hash in the Finished message won't match, and the connection is immediately aborted. It's the cryptographic equivalent of both parties reading back the entire conversation and confirming they heard the same thing.


TLS 1.3: Faster, Simpler, More Secure

TLS 1.3 (RFC 8446, published August 2018) is a major redesign. It's not an incremental update — it's a ground-up rethinking of what should be in the protocol.

sequenceDiagram
    participant C as Client
    participant S as Server

    rect rgb(52, 73, 94)
        Note over C,S: Single Round Trip

        C->>S: ClientHello<br/>• Supported versions (1.3)<br/>• Cipher suites<br/>• Key Share (ECDHE public key!)<br/>• Signature algorithms<br/>• SNI, ALPN extensions

        Note over S: Server computes shared secret<br/>immediately from client's key share

        S->>C: ServerHello<br/>• Selected cipher suite<br/>• Key Share (server's ECDHE pubkey)

        Note over C,S: Handshake keys derived<br/>Everything below is ENCRYPTED

        S->>C: {EncryptedExtensions}
        S->>C: {Certificate}
        S->>C: {CertificateVerify}<br/>(signature over handshake transcript)
        S->>C: {Finished}

        Note over C: Verify certificate, signature,<br/>and Finished hash

        C->>S: {Finished}
    end

    rect rgb(46, 204, 113)
        Note over C,S: Application Data (encrypted)
        C->>S: HTTPS Request
        S->>C: HTTPS Response
    end

    Note over C,S: 1 round trip before first data byte!<br/>(vs 2 round trips in TLS 1.2)

What Changed and Why

The fundamental improvement: In TLS 1.2, the client sends ClientHello, waits for the server to respond with its certificate and DH parameters, and only then sends its own DH value. Two round trips. In TLS 1.3, the client speculatively sends its ECDHE public key in the ClientHello. If the server supports the same curve, the key exchange completes in one round trip.

Everything after ServerHello is encrypted. In TLS 1.2, the Certificate message is sent in plaintext — anyone on the network can see which certificate the server presents. In TLS 1.3, the Certificate is encrypted using handshake traffic keys derived from the initial key share exchange. This is a significant privacy improvement.

Removed insecure features:

Feature RemovedWhy
RSA key exchangeNo forward secrecy — private key compromise decrypts all past traffic
Static DHNo forward secrecy — same issue as RSA
CBC cipher modesPadding oracle attacks (POODLE, Lucky Thirteen, BEAST)
RC4Biased output, statistical attacks, broken since ~2013
SHA-1 in signatureCollision attacks (SHAttered 2017)
TLS compressionInformation leakage (CRIME/BREACH attacks)
RenegotiationComplexity, renegotiation attack (2009), attack surface
Custom DH groupsWeak group attacks (Logjam 2015), precomputation attacks
Export cipher suitesIntentionally weak — 512-bit RSA/DH, breakable in minutes (FREAK 2015)
DES/3DESSmall 64-bit block size enables Sweet32 attack (2016)

What remains in TLS 1.3:

  • Key exchange: ECDHE (X25519, P-256, P-384) or DHE only — PFS is mandatory
  • Encryption: AEAD only — AES-128-GCM, AES-256-GCM, ChaCha20-Poly1305
  • Hash: SHA-256 or SHA-384 for HKDF
  • Signatures: RSA-PSS, ECDSA, Ed25519

TLS 1.3 is more secure precisely because it removed options. Every removed feature was associated with at least one real-world attack. By eliminating insecure options, TLS 1.3 makes it impossible for a misconfigured server to negotiate a weak cipher suite — because weak cipher suites don't exist in the protocol.

TLS 1.0 and 1.1 are officially deprecated (RFC 8996, March 2021). All modern browsers have disabled them. PCI DSS 4.0 requires TLS 1.2 or higher. TLS 1.2 remains acceptable but should be configured to use ONLY AEAD cipher suites with ECDHE key exchange. If you're still supporting TLS 1.0 or 1.1 in production, you are accepting known vulnerabilities with no justification.

0-RTT: Zero Round Trip Time Resumption

TLS 1.3 introduced 0-RTT (zero round trip time) resumption — the client can send encrypted application data in its very first message when reconnecting to a server it has previously communicated with.

sequenceDiagram
    participant C as Client
    participant S as Server

    Note over C,S: Previous session established PSK<br/>(Pre-Shared Key from last connection)

    C->>S: ClientHello<br/>+ early_data extension<br/>+ Key Share<br/>+ PSK identity

    C->>S: {0-RTT Application Data}<br/>Encrypted with PSK-derived keys<br/>HTTP GET /api/data<br/>SENT IMMEDIATELY!

    Note over S: Server can process 0-RTT data<br/>BEFORE handshake completes

    S->>C: ServerHello + Key Share
    S->>C: {EncryptedExtensions}
    S->>C: {Finished}
    S->>C: {Application Data response}

    C->>S: {Finished}

    Note over C,S: First data byte sent with<br/>ZERO round trips of latency!

That sounds great for performance — but the catch is serious: 0-RTT data is not protected against replay attacks. An attacker who captures the ClientHello with 0-RTT data can replay it verbatim, and the server may process the 0-RTT data again.

Think about what that means. If the 0-RTT data is GET /api/latest-news, replaying it returns the same news page — harmless. But if the 0-RTT data is POST /api/transfer?amount=10000&to=attacker, replaying it transfers $10,000 a second time.

0-RTT data is replayable. Servers MUST either:
1. **Reject 0-RTT entirely** (safest — set `ssl_early_data off` in nginx)
2. **Accept 0-RTT only for safe, idempotent requests** (GET with no side effects)
3. **Implement application-layer replay protection** (unique request IDs, idempotency keys)

Never use 0-RTT for:
- Financial transactions
- Authentication/login requests
- Database mutations (INSERT, UPDATE, DELETE)
- Any state-changing operation

CDN providers (Cloudflare, Fastly) support 0-RTT for GET requests to cacheable content, where replay is harmless. They automatically disable it for POST/PUT/DELETE. That's the right approach.

The TLS Record Protocol

Once the handshake completes, all application data is sent as TLS records. Each record is encrypted and authenticated independently.

graph LR
    subgraph RECORD["TLS 1.3 Record"]
        CT["Content Type<br/>(1 byte)<br/>23=app data<br/>21=alert<br/>22=handshake"]
        PV["Legacy Version<br/>(2 bytes)<br/>Always 0x0303"]
        LEN["Length<br/>(2 bytes)<br/>max 16384 + overhead"]
        PAYLOAD["Encrypted Payload<br/>(variable, up to 16384 bytes)<br/>Contains actual content type<br/>+ application data"]
        TAG["AEAD Auth Tag<br/>(16 bytes)<br/>Covers encrypted payload<br/>+ associated data<br/>(CT, PV, LEN)"]
    end

    CT --> PV --> LEN --> PAYLOAD --> TAG

    NOTE["Security properties of each record:<br/>• Unique nonce (from sequence number)<br/>• Encrypted with session key<br/>• Authenticated with AEAD tag<br/>• Tamper = tag verification failure = connection killed<br/>• Reorder = sequence number mismatch = connection killed<br/>• Replay = duplicate sequence number = connection killed"]

    style PAYLOAD fill:#3182ce,color:#fff
    style TAG fill:#38a169,color:#fff
    style NOTE fill:#fff3cd,color:#1a202c

Every record has its own authentication tag, and each record uses a unique nonce derived from the record sequence number. This means: modifying a single byte in any record causes the AEAD tag verification to fail. Reordering records causes a sequence number mismatch. Replaying a record produces a duplicate sequence number. Deleting a record causes a gap in sequence numbers. All of these are detected, and the connection is immediately terminated with a fatal alert. There is no "partial compromise" — any tampering kills the entire connection.

In TLS 1.3, the real content type is encrypted inside the payload (hidden from observers), and the outer content type always reads as "application data" (23). This means a network observer can't even distinguish handshake messages from application data after the initial ServerHello.


Walking Through a Real Wireshark Capture

Here is what a TLS handshake actually looks like on the wire. This is where theory becomes tangible.

# Capture a TLS handshake
$ sudo tcpdump -i en0 -w /tmp/tls_capture.pcap \
    'host example.com and tcp port 443' &
$ curl -s https://example.com > /dev/null
$ kill %1

# Or connect with verbose openssl output
$ openssl s_client -connect example.com:443 -state -debug 2>&1 | head -100
Open a pcap file in Wireshark and use these display filters to isolate specific TLS messages:

All ClientHello messages (see offered cipher suites, SNI, key shares)

tls.handshake.type == 1

All ServerHello messages (see selected cipher suite, server key share)

tls.handshake.type == 2

All Certificate messages (TLS 1.2 only — encrypted in TLS 1.3)

tls.handshake.type == 11

All handshake messages

tls.handshake

See the SNI hostname (visible even in TLS 1.3)

tls.handshake.extensions_server_name

Filter for a specific SNI

tls.handshake.extensions_server_name == "example.com"

See cipher suites offered in ClientHello

tls.handshake.ciphersuite

Application data records (encrypted payload)

tls.record.content_type == 23

TLS alerts (errors, connection closures)

tls.record.content_type == 21


To decrypt TLS 1.3 in Wireshark, you need session keys. Set the `SSLKEYLOGFILE` environment variable:

```bash
# Enable session key logging
export SSLKEYLOGFILE=/tmp/tls_keys.log

# Generate traffic
curl https://example.com

# In Wireshark:
# Edit → Preferences → Protocols → TLS →
#   "(Pre)-Master-Secret log filename": /tmp/tls_keys.log
# Now you can see decrypted application data!

# Chrome also supports SSLKEYLOGFILE for all browser traffic
# Start Chrome: SSLKEYLOGFILE=/tmp/tls_keys.log open -a "Google Chrome"

This is invaluable for debugging TLS issues — you can see the actual HTTP requests and responses inside the encrypted TLS connection.


```admonish warning
The SSLKEYLOGFILE contains session keys that can decrypt all captured traffic. Treat it as a secret:
- Never enable it in production
- Delete it after debugging
- Never commit it to source control
- If an attacker obtains this file AND a packet capture, they can decrypt everything

A History of TLS Attacks

Every major TLS attack exploited one of three categories: (1) legacy features that should have been removed, (2) implementation bugs, or (3) downgrade to a weaker version. Understanding the attacks explains TLS 1.3's design decisions.

timeline
    title TLS Attack Timeline
    2011 : BEAST<br/>CBC weakness in TLS 1.0<br/>Chosen-boundary attack
    2012 : CRIME<br/>TLS compression leaks data<br/>Session cookie recovery
    2013 : BREACH<br/>HTTP compression leaks<br/>Similar to CRIME but HTTP-level
         : Lucky Thirteen<br/>CBC timing side-channel<br/>Padding oracle variant
    2014 : Heartbleed<br/>OpenSSL implementation bug<br/>Server memory disclosure
         : POODLE<br/>SSL 3.0 / CBC padding oracle<br/>Byte-at-a-time decryption
    2015 : FREAK<br/>Export cipher downgrade<br/>512-bit RSA, breakable in hours
         : Logjam<br/>Weak DH downgrade<br/>512-bit DH, precomputation attack
    2016 : DROWN<br/>SSLv2 cross-protocol attack<br/>Decrypt TLS using SSLv2 oracle
         : Sweet32<br/>64-bit block cipher birthday<br/>3DES vulnerable after 2^32 blocks
    2018 : TLS 1.3 published<br/>Removes ALL vulnerable features<br/>Mandatory PFS, AEAD only

The Attacks in Detail

BEAST (2011) — Exploited a known weakness in CBC mode in TLS 1.0. The IV for each record was the last ciphertext block of the previous record (predictable). By injecting chosen plaintext at a block boundary, the attacker could decrypt one byte at a time. Fixed by: TLS 1.1+ (random IVs) and AES-GCM. TLS 1.3 removes CBC entirely.

CRIME (2012) — Exploited TLS-level compression. When TLS compresses the plaintext before encryption, the compressed size reveals information about the plaintext. If the attacker can inject guesses into the request (e.g., via JavaScript), they can recover secrets (like session cookies) by observing which guesses cause smaller compressed output. Fixed by: Disabling TLS compression. TLS 1.3 doesn't support compression.

BREACH (2013) — Similar to CRIME but exploits HTTP-level compression (gzip) instead of TLS compression. Since most servers use gzip, this is harder to fix. Mitigations: Don't reflect user input in compressed responses containing secrets. Add random padding. Use per-request CSRF tokens.

Heartbleed (2014) — Implementation bug in OpenSSL, not a protocol flaw. Covered in Chapter 5. Fixed by: Patching OpenSSL. Using PFS limits the blast radius.

POODLE (2014) — Exploited CBC padding in SSL 3.0. The padding bytes in SSL 3.0 weren't covered by the MAC, so an attacker could modify padding bytes without detection and use the server's "padding error" vs "MAC error" responses as an oracle to decrypt one byte per ~256 requests. A variant (POODLE for TLS) later found similar issues in some TLS implementations. Fixed by: Disabling SSL 3.0 and using AEAD cipher suites.

FREAK (2015) — Forced downgrade to "export-grade" 512-bit RSA cipher suites. These were deliberately weakened in the 1990s to comply with US export regulations. The regulations were lifted, but the code remained. Many servers (including Apple's and Microsoft's TLS stacks) still accepted export cipher suites. A 512-bit RSA key can be factored in about 7 hours on Amazon EC2. Fixed by: Removing export cipher suites from all implementations.

Logjam (2015) — Forced downgrade to 512-bit Diffie-Hellman (export-grade). Additionally showed that many servers used common 1024-bit DH primes, enabling precomputation attacks. Fixed by: Disabling export DH, using ECDHE instead of DHE, minimum 2048-bit DH parameters.

DROWN (2016) — Cross-protocol attack: if a server supports SSLv2 on any port (even a different service), an attacker can use SSLv2's weaknesses as an oracle to decrypt TLS connections that share the same RSA key. Fixed by: Disabling SSLv2 everywhere. Never sharing keys between services with different protocol support.

In 2015, after the FREAK and Logjam attacks, a TLS audit across several hundred servers produced alarming results:

- 40% still supported export cipher suites (from the 1990s!)
- 60% had DH parameters of 1024 bits or less
- 15% still supported SSL 3.0
- 5% supported SSL 2.0 (yes, really)

The servers had been deployed years ago with configurations that were "secure at the time" and never updated. Nobody owned TLS configuration maintenance — the original deployers had moved on, and the operations team treated TLS config as "don't touch it if it works."

This illustrates two lessons. First, TLS configuration is not "set and forget" — it requires regular auditing. Second, the easiest defense is reducing optionality. If your server only supports TLS 1.3, it literally cannot be downgraded to SSL 3.0, because it doesn't speak SSL 3.0.

The fix: quarterly TLS audits using testssl.sh in CI/CD. Any server that doesn't achieve an A rating on SSL Labs is flagged and must be remediated within one sprint. Configuration drift — the slow degradation of security posture — is one of the top operational concerns.

Testing Your TLS Configuration

# Quick test with openssl
$ openssl s_client -connect yoursite.com:443 -brief
CONNECTION ESTABLISHED
Protocol version: TLSv1.3
Ciphersuite: TLS_AES_256_GCM_SHA384
Peer signing digest: SHA256
Peer signature type: ECDSA
Server Temp Key: X25519, 253 bits

# Test if dangerous old TLS versions are enabled
$ openssl s_client -connect yoursite.com:443 -tls1 2>&1 | head -5
# If this connects → TLS 1.0 is enabled (FIX THIS)

$ openssl s_client -connect yoursite.com:443 -tls1_1 2>&1 | head -5
# If this connects → TLS 1.1 is enabled (FIX THIS)

$ openssl s_client -connect yoursite.com:443 -ssl3 2>&1 | head -5
# If this connects → SSL 3.0 is enabled (CRITICAL — FIX IMMEDIATELY)

# Check for weak cipher suites
$ nmap --script ssl-enum-ciphers -p 443 yoursite.com

# Check certificate details
$ echo | openssl s_client -connect yoursite.com:443 2>/dev/null | \
    openssl x509 -noout -text | \
    grep -E "(Subject:|Issuer:|Not Before|Not After|Public Key Algorithm|Signature Algorithm|DNS:)"
Use testssl.sh for a comprehensive TLS audit:

```bash
# Install testssl.sh
git clone --depth 1 https://github.com/drwetter/testssl.sh.git

# Run a full test
./testssl.sh/testssl.sh https://yoursite.com

# It tests:
# - Protocol support (SSL 2/3, TLS 1.0/1.1/1.2/1.3)
# - Cipher suite support and preference order
# - Certificate chain validity and key size
# - Known vulnerabilities (Heartbleed, POODLE, FREAK, Logjam, DROWN, ROBOT)
# - HSTS header and preloading
# - OCSP stapling
# - Certificate Transparency
# - Forward secrecy support
# - Server key exchange parameters

# For JSON output (useful in CI/CD):
./testssl.sh/testssl.sh --jsonfile results.json https://yoursite.com

# Quick vulnerability-only check:
./testssl.sh/testssl.sh --vulnerable https://yoursite.com

Or use the Qualys SSL Labs test for a web-based assessment: https://www.ssllabs.com/ssltest/

Aim for an A+ rating. Common reasons for lower grades:

  • Supporting TLS 1.0 or 1.1 (instant cap at B)
  • Allowing weak cipher suites (RC4, 3DES, export)
  • Missing HSTS header (caps at A instead of A+)
  • Certificate chain issues (missing intermediate, wrong order)
  • Weak DH parameters (< 2048 bits)
  • No OCSP stapling (performance concern, not grade-affecting usually)

---

## Hardened Server Configuration

Here are copy-paste configurations for the two most common web servers.

### Nginx

```nginx
server {
    listen 443 ssl http2;
    listen [::]:443 ssl http2;
    server_name example.com;

    # Certificates
    ssl_certificate     /etc/ssl/certs/example.com-fullchain.pem;
    ssl_certificate_key /etc/ssl/private/example.com.key;

    # Protocol versions — TLS 1.2 and 1.3 only
    ssl_protocols TLSv1.2 TLSv1.3;

    # TLS 1.2 cipher suites — ECDHE + AEAD only
    ssl_ciphers ECDHE-ECDSA-AES128-GCM-SHA256:ECDHE-RSA-AES128-GCM-SHA256:ECDHE-ECDSA-AES256-GCM-SHA384:ECDHE-RSA-AES256-GCM-SHA384:ECDHE-ECDSA-CHACHA20-POLY1305:ECDHE-RSA-CHACHA20-POLY1305;

    # Server chooses cipher suite (not client)
    ssl_prefer_server_ciphers off;  # TLS 1.3 ignores this; for 1.2 both orders are fine with strong-only suites

    # ECDH curve preference
    ssl_ecdh_curve X25519:secp256r1:secp384r1;

    # HSTS — force HTTPS for 2 years, include subdomains, preload
    add_header Strict-Transport-Security "max-age=63072000; includeSubDomains; preload" always;

    # OCSP stapling — faster certificate validation
    ssl_stapling on;
    ssl_stapling_verify on;
    ssl_trusted_certificate /etc/ssl/certs/fullchain.pem;
    resolver 1.1.1.1 8.8.8.8 valid=300s;
    resolver_timeout 5s;

    # Session configuration
    ssl_session_timeout 1d;
    ssl_session_cache shared:SSL:10m;
    ssl_session_tickets off;  # Disable for forward secrecy

    # 0-RTT — disable unless you understand the replay risk
    # ssl_early_data off;  # Default in most nginx versions

    # Security headers (bonus)
    add_header X-Content-Type-Options "nosniff" always;
    add_header X-Frame-Options "DENY" always;
    add_header Content-Security-Policy "default-src 'self'" always;
}

# Redirect HTTP to HTTPS
server {
    listen 80;
    listen [::]:80;
    server_name example.com;
    return 301 https://$host$request_uri;
}

Apache

<VirtualHost *:443>
    ServerName example.com

    SSLEngine on
    SSLCertificateFile    /etc/ssl/certs/example.com.pem
    SSLCertificateKeyFile /etc/ssl/private/example.com.key
    SSLCertificateChainFile /etc/ssl/certs/chain.pem

    # Protocol versions
    SSLProtocol -all +TLSv1.2 +TLSv1.3

    # Cipher suites (TLS 1.2)
    SSLCipherSuite ECDHE-ECDSA-AES128-GCM-SHA256:ECDHE-RSA-AES128-GCM-SHA256:ECDHE-ECDSA-AES256-GCM-SHA384:ECDHE-RSA-AES256-GCM-SHA384:ECDHE-ECDSA-CHACHA20-POLY1305:ECDHE-RSA-CHACHA20-POLY1305

    # TLS 1.3 cipher suites (separate directive in Apache 2.4.53+)
    SSLCipherSuite TLSv1.3 TLS_AES_256_GCM_SHA384:TLS_CHACHA20_POLY1305_SHA256:TLS_AES_128_GCM_SHA256

    SSLHonorCipherOrder on

    # HSTS
    Header always set Strict-Transport-Security "max-age=63072000; includeSubDomains; preload"

    # OCSP stapling
    SSLUseStapling On
    SSLStaplingCache "shmcb:logs/ssl_stapling(32768)"

    # Disable session tickets for PFS
    SSLSessionTickets Off
</VirtualHost>

# Redirect HTTP to HTTPS
<VirtualHost *:80>
    ServerName example.com
    Redirect permanent / https://example.com/
</VirtualHost>

Why disable session tickets? TLS session tickets encrypt the session state with a symmetric key (the Session Ticket Encryption Key, STEK). If this key is compromised, all sessions resuming with tickets encrypted by it can be decrypted — defeating forward secrecy. The STEK must be rotated frequently (every few hours) and must never be written to disk. Most web servers handle STEK rotation poorly or not at all, so it's safer to disable tickets unless you've verified that key rotation is properly implemented. In TLS 1.3, session tickets are encrypted with keys derived from the resumption master secret, which provides better forward secrecy properties, but the STEK concern remains.


Certificate Transparency

Even if the TLS protocol is perfect, the certificate system has a structural weakness: any of the ~150 root CAs trusted by browsers can issue a certificate for any domain. If any one of them is compromised or misbehaves, they could issue a fraudulent certificate for google.com, and browsers would accept it.

This has happened multiple times:

  • DigiNotar (2011): Compromised, issued fraudulent certificates for google.com. Used for MITM surveillance of Gmail users in Iran. The CA was destroyed — removed from all trust stores.
  • CNNIC (2015): A subordinate CA used a CNNIC-signed intermediate certificate to issue unauthorized certificates for Google domains via a MitM proxy.
  • Symantec (2015-2017): Caught issuing test certificates for domains they didn't own, including google.com. Chrome gradually distrusted all Symantec certificates, forcing a mass migration.
  • Let's Encrypt incident (2022): Not malicious, but a bug caused millions of certificates to be issued without proper domain validation checks. All were revoked within days.

Certificate Transparency (CT) is the solution. Every certificate issued by a CA must be submitted to multiple public, append-only CT logs before it's considered valid. Anyone can monitor these logs.

# Search Certificate Transparency logs for your domain
# Using the crt.sh database:
$ curl -s "https://crt.sh/?q=example.com&output=json" | \
    python3 -c "import json,sys; [print(c['not_before'], c['issuer_name'][:60], c['name_value'][:40]) for c in json.load(sys.stdin)[:10]]"

# This shows all certificates ever issued for example.com
# Monitor for unexpected certificates — possible fraudulent issuance

# Check if a specific certificate has SCTs (Signed Certificate Timestamps)
$ echo | openssl s_client -connect example.com:443 2>/dev/null | \
    openssl x509 -noout -text | grep -A2 "CT Precertificate SCTs"
Chrome has required Certificate Transparency since April 2018. Any certificate not logged in at least two independent CT logs is rejected with a certificate error. This creates a powerful enforcement mechanism:

- If a CA issues a fraudulent certificate AND logs it → the fraud is visible in public logs, and the domain owner (or automated monitoring) detects it
- If a CA issues a fraudulent certificate and DOESN'T log it → Chrome rejects the certificate as untrusted

Either way, the fraudulent certificate is useless for attacking Chrome users. CT doesn't prevent fraudulent issuance, but it makes it detectable and creates accountability.

Domain owners should monitor CT logs for unexpected certificates:
- **crt.sh**: Free web interface and API for searching CT logs
- **CertSpotter** (SSLMate): Free monitoring service, email alerts for new certificates
- **Facebook CT Monitor**: Another free monitoring option
- **Google Certificate Transparency Dashboard**: Visualizes CT log data

Set up monitoring for your domains. If someone manages to convince a CA to issue a certificate for your domain (via social engineering, domain validation bypass, or CA compromise), you want to know immediately.

TLS Beyond the Browser

TLS isn't just for HTTPS. Many other protocols depend on it:

ProtocolTLS UsagePort
HTTPSHTTP over TLS443
SMTPSEmail submission over TLS465
IMAPSEmail retrieval over TLS993
LDAPSDirectory over TLS636
FTPSFile transfer over TLS990
MQTT over TLSIoT messaging over TLS8883
gRPCUses HTTP/2 over TLS by defaultvaries
PostgreSQLOptional TLS (sslmode=require)5432
MySQLOptional TLS (--ssl-mode=REQUIRED)3306
RedisOptional TLS (since Redis 6)6379

There's also mTLS (mutual TLS), where BOTH the client and server present certificates. The server verifies the client's identity, not just the other way around. This is the foundation of zero-trust service-to-service authentication in microservices architectures. Service meshes like Istio and Linkerd automate mTLS between all services, ensuring that every internal connection is authenticated and encrypted.


What You've Learned

This chapter demystified TLS, the protocol that secures virtually every connection on the internet:

  • TLS provides three properties simultaneously: authentication (certificate chain verification), confidentiality (AEAD encryption), and integrity (authentication tags on every record). All three are required — removing any one breaks the security model.
  • The TLS 1.2 handshake consists of ClientHello, ServerHello, Certificate, ServerKeyExchange, ClientKeyExchange, and Finished messages across two round trips. Each message serves a specific cryptographic purpose.
  • Cipher suite notation encodes the key exchange algorithm, authentication algorithm, symmetric cipher, AEAD mode, and hash function. TLS 1.3 simplified the notation by separating key exchange negotiation from the cipher suite.
  • TLS 1.3 reduces the handshake to one round trip by including the client's key share in the ClientHello. It removes all insecure features (CBC, RC4, static RSA, export ciphers, compression), encrypts the handshake itself, and mandates Perfect Forward Secrecy.
  • 0-RTT resumption allows encrypted data in the first message but is vulnerable to replay attacks. Use it only for idempotent operations or disable it entirely.
  • The TLS record protocol encrypts and authenticates each record independently with unique nonces. Any tampering, reordering, or replay is detected and kills the connection.
  • Every major TLS attack (BEAST, CRIME, POODLE, FREAK, Logjam, DROWN, Sweet32) exploited legacy features, implementation bugs, or protocol downgrades. TLS 1.3 eliminates the first and third categories by design.
  • Certificate Transparency makes fraudulent certificate issuance detectable by requiring public logging. Monitor CT logs for your domains.
  • Hardened configuration means: TLS 1.2+ only, AEAD cipher suites with ECDHE only, HSTS with preloading, OCSP stapling, session tickets disabled (or with proper key rotation), and regular auditing with testssl.sh or SSL Labs.

You now understand the cryptographic building blocks — symmetric encryption, asymmetric encryption, hashing, MACs, digital signatures, and key exchange — and how TLS composes them into a secure communication protocol. Every HTTPS connection, every API call, every git push, every database query over TLS uses these mechanisms. They're not abstract concepts — they're running right now, protecting your data.

The goal isn't a perfect score on SSL Labs. The goal is understanding what each configuration choice means, why it matters, and what trade-off you're making. A senior engineer doesn't just follow a checklist — they understand the reasoning behind every line.