TLS / HTTPS Deep Dive

HTTPS is "the thing with the padlock" but the engineering behind it has changed significantly over the last decade. In this guide we'll walk through the TLS handshake (1.2 versus 1.3), what's actually in a cipher suite name, why forward secrecy matters, how certificate trust works in practice, and how to read a real certificate chain with openssl. The canonical reference for TLS 1.3 is RFC 8446; for the older TLS 1.2 it's RFC 5246.

Real-World Scenario

A monitoring page is throwing certificate warnings. Is it the chain (intermediate not served)? Is it the SAN (cert covers example.com but the browser asked for api.example.com)? Is it the trust store (corporate proxy injecting its own CA)? After this guide we'll be able to diagnose all three with a single openssl s_client command.

The Handshake: TLS 1.2 vs TLS 1.3

The handshake's job is to (1) prove the server's identity, (2) agree on cryptographic parameters, and (3) derive symmetric keys for the rest of the conversation. TLS 1.3 changed how it does this and cut a round-trip out of the process.

TLS 1.2 — Two Round Trips

TLS 1.2 negotiates in the clear first and only switches to encryption once both sides have exchanged key material. That ordering costs two round trips, and it leaves the certificate — and therefore the hostname you are visiting — readable to anyone on the path.

The TLS 1.2 handshake, which costs two round trips The client sends a ClientHello listing its supported versions, cipher suites, a random nonce and the SNI hostname. The server replies with ServerHello, its certificate chain in cleartext, a ServerKeyExchange carrying signed ECDHE parameters, and ServerHelloDone. That is the first round trip. The client then sends its ClientKeyExchange public value followed by ChangeCipherSpec and Finished, and the server answers with its own ChangeCipherSpec and Finished. That is the second round trip. Only then can encrypted application data flow. Client sends the first message Server holds the certificate ClientHello supported versions · cipher suite list · client random · SNI ServerHello chosen cipher suite · server random Certificate the leaf and intermediate chain — sent in cleartext ServerKeyExchange ECDHE curve and public value, signed with the certificate key ServerHelloDone round trip 1 ClientKeyExchange client ECDHE public value — both ends now derive the master secret ChangeCipherSpec · Finished switch to the negotiated keys; Finished is a MAC over the transcript ChangeCipherSpec · Finished the server proves it derived the same keys round trip 2 Application data — encrypted both ways Two full round trips before the first byte of HTTP leaves the client.
TLS 1.2 spends two round trips on the handshake, and the certificate travels in cleartext where anyone on the path can read it.

TLS 1.3 — One Round Trip

TLS 1.3 collapses the handshake. The client guesses the key-share parameters in the first message; the server responds with everything it needs to derive session keys. By the second flight the client can already send encrypted data.

The TLS 1.3 handshake, which costs one round trip The client sends a ClientHello that already contains a guessed key_share, along with cipher suites, signature_algorithms, SNI and ALPN. The server replies with ServerHello carrying its own key_share, at which point both ends can derive handshake keys. Everything the server sends after that — EncryptedExtensions, Certificate, CertificateVerify and Finished — is encrypted under the handshake traffic key, so the certificate is no longer visible on the wire. The client answers with its own encrypted Finished. Application data flows after a single round trip. Client sends the first message Server holds the certificate ClientHello key_share — a guessed ECDHE public value — plus cipher suites, signature_algorithms, SNI and ALPN ServerHello chosen suite · server key_share — both ends can now derive handshake keys Everything below is encrypted under the handshake traffic key {EncryptedExtensions} ALPN result and other extensions — no longer visible on the wire {Certificate} · {CertificateVerify} the chain, plus a signature over the transcript proving the server holds the key {Finished} MAC over the transcript — the server’s flight is complete {Finished} the client validates the chain and confirms — also encrypted round trip 1 — the only one Application data — application traffic keys One round trip before application data. TLS 1.2 needed two.
TLS 1.3 puts a key share in the very first message, so the server can start encrypting immediately — the certificate included.

Curly braces mean "encrypted under the handshake key." Everything after ServerHello is hidden from on-path observers — including the server's certificate, which used to be in cleartext in TLS 1.2.

0-RTT and the Replay Risk

TLS 1.3 also supports 0-RTT ("early data") — the client can append application data to its very first message, encrypted with a pre-shared key from a previous session. The catch is that 0-RTT data is not protected against replay: an attacker who captures the request can resend it. Use 0-RTT only for idempotent operations (GETs that don't mutate state), and never for anything that creates orders, charges cards, or changes state.

Cipher Suite Anatomy

A cipher suite is the package of cryptographic primitives both ends agree to use. The naming conventions are very different between TLS 1.2 and TLS 1.3.

TLS 1.2 names

Example: ECDHE-RSA-AES256-GCM-SHA384

  • ECDHE — Elliptic-Curve Diffie-Hellman Ephemeral key exchange (provides forward secrecy)
  • RSA — the server's certificate uses an RSA key for authentication (signing the handshake)
  • AES256-GCM — symmetric cipher: AES with a 256-bit key in Galois/Counter mode (authenticated encryption)
  • SHA384 — the HMAC/PRF hash for handshake integrity

TLS 1.3 names

Example: TLS_AES_256_GCM_SHA384

TLS 1.3 only negotiates the symmetric cipher and PRF hash in the suite name. The key exchange method is negotiated separately (supported_groups) and the signature algorithm is negotiated separately (signature_algorithms). This is a cleaner separation. There are only five named suites in TLS 1.3:

  • TLS_AES_128_GCM_SHA256
  • TLS_AES_256_GCM_SHA384
  • TLS_CHACHA20_POLY1305_SHA256
  • TLS_AES_128_CCM_SHA256
  • TLS_AES_128_CCM_8_SHA256

Forward Secrecy and Why ECDHE Matters

If an attacker records today's TLS traffic and later steals the server's private key, can they decrypt the recorded sessions? With old RSA key exchange, yes — the session key was encrypted to the server's RSA public key, so anyone with the private key can recover it. With ECDHE (or DHE), every session uses an ephemeral keypair that is thrown away after the handshake. Even if the long-term private key leaks tomorrow, today's recorded session is still safe.

TLS 1.3 makes this non-negotiable: every TLS 1.3 connection has forward secrecy. In TLS 1.2 we have to explicitly disable RSA key exchange and require an ECDHE/DHE-based suite.

Why forward secrecy depends on the key exchange, not the certificate Two columns compare the same attack. With RSA key exchange the client encrypts the session key to the server’s long-term public key and sends it, so an attacker who records the traffic and later obtains that private key can decrypt every stored session. With ECDHE each side contributes a throwaway keypair and the shared secret is never transmitted; the long-term key only signs the handshake. When it leaks later, the ephemeral halves are already destroyed and the recorded sessions stay unreadable. RSA key exchange TLS 1.2 and earlier · removed in TLS 1.3 1 · The handshake today The client picks the session key and encrypts it to the server’s long-term RSA public key. 2 · An observer records the traffic 3 · The private key leaks next year That same key decrypts the recorded ClientKeyExchange, which carries the session key — so every stored session becomes readable. ECDHE key exchange optional in TLS 1.2 · mandatory in TLS 1.3 1 · The handshake today Each side sends a throwaway public value and derives the shared secret. The private halves stay put. 2 · An observer records the traffic 3 · The private key leaks next year That key only ever signed the handshake. The ephemeral halves were destroyed when the session ended, so the recording stays unreadable.
Forward secrecy is a property of the key exchange, not of the certificate. The certificate key signs; only the ephemeral key exchange decides what a future leak exposes.

Certificates and the Chain

A server certificate is just a signed assertion that "this public key belongs to this hostname." The signature comes from an intermediate CA, which is itself signed by a root CA already trusted by the client. Browsers and OSes ship a list of root CAs. The chain is leaf → intermediate(s) → root.

A certificate chain: which parts the server sends and which the client already has The root CA certificate, ISRG Root X1, is self-signed and already present in the client’s trust store, so the server must not send it. The server sends two certificates: the intermediate CA, R10, which is signed by the root, and the leaf certificate for example.com, which is signed by the intermediate. Signing runs downwards from root to intermediate to leaf; validation runs upwards from the leaf back to a root the client already trusts. Omitting the intermediate is the common cause of a site that works in Chrome but fails in curl. Already in the client’s trust store Root CA — ISRG Root X1 self-signed · ships with the OS or browser The client already trusts this one. Sending it again only wastes handshake bytes. Sent by your server in the Certificate message — fullchain.pem Intermediate CA — R10 signed by the root · rotated every few years Serve this. Leaving it out is the classic “works in Chrome, fails in curl” bug. Leaf — example.com signed by the intermediate · 90-day lifetime The hostname is matched against this cert’s SAN list, never the deprecated CN field. signs signs Validation runs the other way: the client checks the leaf against the intermediate, then the intermediate against a root it already holds.
The server sends the leaf and every intermediate. The root stays where it already is: in the client’s trust store.

Important: the server must send the leaf and the intermediate(s). It must not send the root — the client already has it. The classic "works in Chrome, breaks in curl" bug is a missing intermediate.

SAN, Not CN: Hostname Validation

The Subject Common Name (CN) field of a certificate is deprecated for hostname matching. Modern clients ignore it. Hostname matching is done against the Subject Alternative Name (SAN) extension, as specified in RFC 6125 §6.4 and reinforced by CA/Browser Forum baseline requirements. Chrome enforced SAN-only matching from version 58 onward (2017).

This means a certificate that says CN=example.com but lists only SAN: api.example.com will validate for api.example.com and fail for example.com. Always request your certs with the exact SAN list you intend to use.

Trust Stores

Different runtimes consult different trust stores. When a connection works in one place and fails in another, this is often why:

  • System trust store — On Windows, the Certificate Store (managed by certlm.msc and crypt32). On macOS, the system keychain. On Linux distros, /etc/ssl/certs/ca-certificates.crt (Debian/Ubuntu) or /etc/pki/ca-trust/extracted/pem/tls-ca-bundle.pem (RHEL/Rocky). curl, wget, and most CLI tools use this by default.
  • Mozilla NSS — Firefox and some other Mozilla-derived tools ship their own root list. Mozilla's CA list is the de-facto upstream for the trust bundles that go into other distros.
  • Java cacerts$JAVA_HOME/lib/security/cacerts. Java applications get their own list and you import with keytool -import. This is one of the most common gotchas: curl works, the Java service doesn't.
  • Language-specific bundles — Python's certifi package, Node.js's bundled certs (overridden by NODE_EXTRA_CA_CERTS), Go's x509.SystemCertPool(). Each may differ slightly.

Certificate Transparency

Certificate Transparency (CT) requires CAs to publish every certificate they issue to public, append-only logs. The browser then refuses certificates that don't appear in two or more logs (with Signed Certificate Timestamps, SCTs, stapled in the cert or via OCSP). CT lets domain owners monitor what's been issued in their name — sites like crt.sh let us search the logs.

The Expect-CT HTTP header was the mechanism for opting into stricter CT enforcement during the rollout; it has been deprecated now that CT is mandatory for all publicly-trusted certs in major browsers.

Revocation: OCSP and OCSP Stapling

If a private key is compromised, the CA needs a way to mark the certificate as revoked. The original answer was CRLs (Certificate Revocation Lists, RFC 5280) — large files the client downloaded. OCSP (Online Certificate Status Protocol, RFC 6960) replaced them with a real-time query, but that meant every TLS connection caused a side-channel HTTP request to the CA, leaking what the client was visiting.

OCSP stapling (RFC 6066) fixes the privacy and performance problems: the server fetches the OCSP response itself, caches it, and "staples" it into the TLS handshake. The client doesn't need to talk to the CA.

OCSP Must-Staple is a TLS feature extension (RFC 7633) added to the certificate itself: it tells the client "if you don't see a stapled OCSP response in the handshake, reject the connection." Without Must-Staple, a missing OCSP response is treated as soft-fail, which is what attackers count on.

HSTS and Preload

HTTP Strict Transport Security (RFC 6797) is a server-sent header that tells the browser "never connect to me over plain HTTP again." It defends against SSL-strip and accidental downgrades.

HSTS header

Strict-Transport-Security: max-age=63072000; includeSubDomains; preload
  • max-age — how long the browser should remember the policy (63072000 = 2 years).
  • includeSubDomains — extend the policy to every subdomain. Don't add this if you have legacy HTTP-only subdomains.
  • preload — request inclusion in the browser-shipped HSTS preload list (hstspreload.org). Once you're on the list and Chromium ships a release, you cannot easily get off. Treat it as a one-way door.

Local Development with mkcert

mkcert is a small utility that creates a local development CA, installs it into the system trust store and the major language runtimes, and then issues certificates signed by that CA for hostnames like localhost, *.local, or your dev domain. Browsers see "valid certificate" because they trust the local root.

Install and use mkcert

# Install (macOS)
brew install mkcert nss

# Install the local CA into the trust store
mkcert -install

# Issue a cert for localhost + custom dev hostname
mkcert localhost dev.example.test 127.0.0.1 ::1

# Output:
# ./localhost+3.pem        # leaf certificate
# ./localhost+3-key.pem    # private key

This avoids the bad habit of testing with self-signed certs and --insecure flags — which inevitably get copy-pasted into production.

Reading a Live Certificate with openssl s_client

openssl s_client is the swiss army knife for diagnosing TLS. The flag combination we want most often is:

Inspect a live TLS endpoint

openssl s_client -connect example.com:443 -servername example.com -showcerts < /dev/null

Flag by flag:

  • -connect host:port — TCP target.
  • -servername host — sends the SNI (Server Name Indication) extension. Modern web servers serve different certs based on SNI, so without this you'll get a default cert that often looks "wrong."
  • -showcerts — print every certificate the server sends, in PEM format.
  • < /dev/null — close stdin so the command exits after the handshake. Otherwise it hangs waiting for HTTP input.

The output (paraphrased example) looks like this:

CONNECTED(00000005)
depth=2 C = US, O = Internet Security Research Group, CN = ISRG Root X1
verify return:1
depth=1 C = US, O = Let's Encrypt, CN = R10
verify return:1
depth=0 CN = example.com
verify return:1
---
Certificate chain
 0 s:CN = example.com
   i:C = US, O = Let's Encrypt, CN = R10
 1 s:C = US, O = Let's Encrypt, CN = R10
   i:C = US, O = Internet Security Research Group, CN = ISRG Root X1
---
SSL handshake has read 4521 bytes and written 386 bytes
---
Protocol  : TLSv1.3
Cipher    : TLS_AES_256_GCM_SHA384
Verification: OK

Things to look at:

  • depth=0/1/2 — leaf, intermediate, root. If depth=1 is missing, the server isn't sending the intermediate.
  • VerificationOK means the chain validated against the client's trust store.
  • Protocol & Cipher — the negotiated parameters. If we see TLSv1.0 or TLSv1.1, the server needs upgrading.

Reading a PEM with openssl x509

Save a certificate to a file and decode it:

Inspect a PEM file

openssl x509 -in cert.pem -noout -text

Useful fields:

  • Subject: — the named entity (often only CN=... for legacy reasons).
  • Issuer: — the CA that signed this certificate.
  • Validity / Not Before / Not After — start and expiry timestamps.
  • Subject Public Key Info — algorithm (e.g. id-ecPublicKey with curve prime256v1) and the public key bytes.
  • X509v3 Subject Alternative Name — the actual hostnames the cert is valid for. This is the field that matters.
  • X509v3 Extended Key UsageTLS Web Server Authentication for server certs.
  • Authority Information Access — URLs for OCSP and the issuer's certificate.
  • CT Precertificate SCTs — the signed timestamps from CT logs.

Quick one-liners:

Common cert inspections

# Just the SAN
openssl x509 -in cert.pem -noout -ext subjectAltName

# Expiry only
openssl x509 -in cert.pem -noout -enddate

# Fingerprint (handy for comparing against trusted-pin lists)
openssl x509 -in cert.pem -noout -fingerprint -sha256

Worked Example: Reading a Chain

Let's say openssl s_client printed two certificates. We split them into leaf.pem and intermediate.pem and want to verify the chain manually.

Manual chain verification

# Confirm the issuer of the leaf matches the subject of the intermediate
openssl x509 -in leaf.pem         -noout -issuer
openssl x509 -in intermediate.pem -noout -subject

# Build a chain file and verify against the system trust store
cat leaf.pem intermediate.pem > fullchain.pem
openssl verify -CAfile /etc/ssl/certs/ca-certificates.crt fullchain.pem

# Or verify with an explicit intermediate set:
openssl verify -untrusted intermediate.pem leaf.pem

If the verify step reports unable to get local issuer certificate, either the intermediate is missing or the root isn't in the trust store we passed in.

Picking Sensible Server Defaults

The Mozilla SSL Configuration Generator is the standard go-to: it generates ready-to-paste config blocks for Nginx, Apache, HAProxy, etc., at three opinionated levels (Modern, Intermediate, Old). Pick Modern when you can — TLS 1.3 only, three suites, ECDSA preferred. Pick Intermediate if you still need to support older clients. Old exists only for legacy systems and should be a deliberate choice.

A modern Nginx baseline looks roughly like this:

Nginx — modern TLS

ssl_certificate     /etc/letsencrypt/live/example.com/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/example.com/privkey.pem;

ssl_protocols TLSv1.3;
# If you need TLS 1.2 too, add TLSv1.2 and set:
# ssl_ciphers "ECDHE-ECDSA-AES128-GCM-SHA256:ECDHE-RSA-AES128-GCM-SHA256:...";
# ssl_prefer_server_ciphers off;

ssl_session_timeout 1d;
ssl_session_cache shared:MozSSL:10m;
ssl_session_tickets off;

ssl_stapling on;
ssl_stapling_verify on;
resolver 1.1.1.1 8.8.8.8 valid=300s;

add_header Strict-Transport-Security "max-age=63072000; includeSubDomains" always;

Summary

TLS isn't magic, it's a layered protocol with surprisingly clear parts: a handshake, a chain, a cipher suite, and the side-channels that tell the client whether the cert was revoked. Get the chain right, enable HSTS, prefer TLS 1.3, and avoid 0-RTT for anything stateful. When something does go wrong, an openssl s_client session and an openssl x509 -text almost always tell you which knob to turn.