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_SHA256TLS_AES_256_GCM_SHA384TLS_CHACHA20_POLY1305_SHA256TLS_AES_128_CCM_SHA256TLS_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 withkeytool -import. This is one of the most common gotchas:curlworks, the Java service doesn't. - Language-specific bundles — Python's
certifipackage, Node.js's bundled certs (overridden byNODE_EXTRA_CA_CERTS), Go'sx509.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=1is missing, the server isn't sending the intermediate. - Verification —
OKmeans the chain validated against the client's trust store. - Protocol & Cipher — the negotiated parameters. If we see
TLSv1.0orTLSv1.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-ecPublicKeywith curveprime256v1) 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 Usage —
TLS Web Server Authenticationfor 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.