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

Client                                              Server
  | --- ClientHello (versions, cipher list, ---->    |
  |     random nonce, extensions, SNI)               |
  |                                                  |
  | <--- ServerHello (chosen cipher, random) -----   |
  | <--- Certificate (server chain)                  |
  | <--- ServerKeyExchange (ECDHE params, signature) |
  | <--- ServerHelloDone                             |
  |                                                  |
  | --- ClientKeyExchange (ECDHE public) --------->  |
  | --- ChangeCipherSpec, Finished ----------------->|
  |                                                  |
  | <--- ChangeCipherSpec, Finished -----------------|
  |                                                  |
  | === Encrypted application data === both ways === |

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.

Client                                              Server
  | --- ClientHello (versions, ciphers, --------->   |
  |     key_share, signature_algorithms,             |
  |     SNI, ALPN, ...)                              |
  |                                                  |
  | <--- ServerHello (chosen suite, key_share) -----|
  | <--- {EncryptedExtensions}                       |
  | <--- {Certificate}                               |
  | <--- {CertificateVerify}                         |
  | <--- {Finished}                                  |
  |                                                  |
  | --- {Finished} --------------------------------->|
  | === Encrypted application data === both ways === |

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.

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.

[Root CA: ISRG Root X1]              (in OS/browser trust store)
            |
            v signs
[Intermediate CA: R10 / R11]         (served by your web server)
            |
            v signs
[Leaf certificate for example.com]   (served by your web server)

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.