DevLift
Back to Blog

How TLS Works Under the Hood

TLS isn't just a padlock — it's a carefully choreographed cryptographic handshake. Here's what actually happens between your browser and the server, with the openssl commands to see each step yourself.

Admin
March 6, 202611 min read0 views

How TLS Works Under the Hood

Your browser shows a padlock. You're told the connection is "secure." But what is actually happening between your laptop and the server when you load https://github.com? If your answer is "something with certificates and encryption," you're right, but you're missing the part that matters — the mechanics that make it impossible for someone sitting between you and the server to read or tamper with your traffic.

TLS (Transport Layer Security) is the protocol behind HTTPS, and once you understand how it works, you'll debug certificate errors faster, design service-to-service auth properly, and stop treating the padlock as magic.

The Mental Model

TLS solves a hard problem: two strangers on an untrusted network need to exchange encrypted messages, but they haven't agreed on a shared key yet. You can't send the key in plaintext — that defeats the purpose.

The solution is a handshake that uses asymmetric cryptography (public/private key pairs) to establish a shared secret, then switches to fast symmetric encryption for the actual data. Asymmetric crypto is the setup. Symmetric crypto is the throughput.

Rendering diagram...

This is TLS 1.3 — one round trip from TCP connection to encrypted application data. TLS 1.2 took two. That difference is not trivial when you're loading a page from the other side of the world.

The TLS 1.3 Handshake, Step by Step

ClientHello

The client fires first. The ClientHello message contains:

  • supported_versions: the TLS versions the client supports (TLS 1.3, TLS 1.2, etc.)
  • cipher_suites: ordered list of algorithm combinations the client accepts — in TLS 1.3 these are all AEAD ciphers: TLS_AES_256_GCM_SHA384, TLS_CHACHA20_POLY1305_SHA256, TLS_AES_128_GCM_SHA256
  • key_share: critically, the client doesn't wait for the server to pick a key exchange method. It guesses (correctly, most of the time) and sends its ECDHE public key for X25519 and/or P-256 right away

This bet-on-the-server's-preference is what cuts the round trip. In TLS 1.2, the client had to wait for the server's cipher choice before it could send key material.

ServerHello + Key Derivation

The server picks a cipher and responds with its own ECDHE public key in the key_share extension. At this point, both sides have what they need:

Client: server_public_key + client_private_key → shared_secret
Server: client_public_key + server_private_key → shared_secret

The math behind ECDHE (Elliptic Curve Diffie-Hellman Ephemeral) guarantees that both sides derive the same shared secret without ever transmitting it. An eavesdropper who sees both public keys cannot compute the shared secret without solving the elliptic curve discrete logarithm problem, which is computationally infeasible.

HKDF Key Schedule

The raw ECDHE output isn't used directly as an encryption key. TLS 1.3 runs it through a layered key derivation function called HKDF (HMAC-based Extract-and-Expand KDF) to produce multiple distinct keys:

Early Secret = HKDF-Extract(0, PSK or 0)
    ↓
Handshake Secret = HKDF-Extract(Early Secret, ECDHE output)
    ├── client_handshake_traffic_secret → client writes key
    └── server_handshake_traffic_secret → server writes key
    ↓
Master Secret = HKDF-Extract(Handshake Secret, 0)
    ├── client_application_traffic_secret → app-layer encryption
    ├── server_application_traffic_secret
    └── resumption_master_secret → session tickets

This is why TLS 1.3 is secure even if you later compromise one session's key — the ephemeral ECDHE keys are discarded after the handshake, so past traffic stays protected. This property is called forward secrecy, and it's why TLS 1.2's RSA key exchange was a problem: if you recorded traffic and later stole the server's private key, you could decrypt everything.

💡

TLS 1.3 mandates forward secrecy — RSA and static DH key exchange were entirely removed. Every connection uses an ephemeral key pair that's thrown away after the handshake completes.

Server Authentication

After ServerHello, everything is encrypted with the handshake keys derived above. The server sends:

EncryptedExtensions — server-side extensions like ALPN (application protocol, e.g., h2 for HTTP/2), server name confirmation, etc.

Certificate — the server's certificate chain: leaf cert → intermediate CA cert(s). The leaf certificate contains the server's public key and is signed by the intermediate CA.

CertificateVerify — the server signs a hash of the entire handshake transcript so far using its private key (from the leaf certificate). This proves the server actually possesses the private key corresponding to the public key in the certificate.

Finished — an HMAC over the handshake transcript, using a key derived from the handshake secret. This authenticates the entire handshake and prevents tampering.

The client verifies the signature in CertificateVerify and sends its own Finished. Both sides can now derive the application traffic keys and start sending data.

Certificate Chain Validation

The CertificateVerify signature proves the server owns the private key. But how do you know the certificate itself is legitimate and not forged?

Rendering diagram...

Root CAs are baked into your operating system or browser, and there is no single number for "how many" because there is no single store. Two you can count yourself, as of 2026-08-03:

# The Mozilla-derived bundle most Linux distros ship
grep -c "BEGIN CERTIFICATE" /etc/ssl/certs/ca-certificates.crt
# 122
 
# Chrome ships its own, independent of the OS
curl -s "https://chromium.googlesource.com/chromium/src/+/main/net/data/ssl/chrome_root_store/root_store.certs?format=TEXT" \
  | base64 -d | grep -c "BEGIN CERTIFICATE"
# 104

So roughly a hundred, not several hundred, and the two lists are not identical — which is exactly why a certificate can validate in Chrome and fail in curl on the same machine. Each root belongs to an operator (DigiCert, Let's Encrypt, Sectigo and so on) that has been audited and admitted by each store's own program.

Intermediate CAs sit between root CAs and leaf certs. Root CA private keys are kept offline in air-gapped HSMs. The intermediate CA does the day-to-day signing, and if it's compromised, it can be revoked without touching the root.

Leaf certificates are issued for specific domains. They contain:

  • The public key the server will use
  • The domain name(s) in the Subject Alternative Names (SANs) extension
  • A validity window
  • The issuing CA's signature

That validity window is on a schedule now, and any number you learned before 2026 is stale. CA/Browser Forum ballot SC-081v3 (passed April 2025) set out a staircase: the old 398-day maximum dropped to 200 days on 15 March 2026, drops to 100 days in March 2027, and to 47 days in March 2029. Since today is past the first step, the current ceiling for a newly issued public TLS certificate is 200 days. Certificates issued before the cutover keep their original window — the rule is prospective — so you will see both in the wild for a while.

In practice most certs are already far shorter than the ceiling. Both github.com and letsencrypt.org were serving 90-day certificates when I checked; you can see it in one line:

echo | openssl s_client -connect github.com:443 2>/dev/null | openssl x509 -noout -dates
# notBefore=Jul  3 00:00:00 2026 GMT
# notAfter=Sep 30 23:59:59 2026 GMT

To validate the chain, the client verifies each signature from the leaf upward, until it hits a cert in its trust store. If any signature is invalid, or the chain doesn't terminate at a trusted root, the browser shows a certificate error.

OCSP Stapling and Certificate Transparency

Two mechanisms extend this trust model:

OCSP Stapling — and the fact that OCSP is being dismantled. The original story: Certificate Revocation Lists were considered too slow for real-time validation, so OCSP (Online Certificate Status Protocol) let clients query the CA per-certificate — which leaked browsing history to the CA, since the CA now learns which site you visited from which IP. OCSP Stapling flipped that: the server pre-fetches a signed OCSP response and "staples" it into the TLS handshake, so the client gets revocation status without contacting the CA.

The privacy problem was fatal to OCSP itself, not just to the un-stapled form of it. Let's Encrypt announced in December 2024 that it was moving to CRLs exclusively, "primarily because it represents a considerable risk to privacy on the Internet", and executed on a published timeline: 7 May 2025 they dropped OCSP URLs from newly issued certificates, and 6 August 2025 they turned off their OCSP responders.

You can watch the split happen in the field. Ask two certificates for their OCSP responder:

echo | openssl s_client -connect github.com:443 2>/dev/null | openssl x509 -noout -ocsp_uri
# http://ocsp.sectigo.com
 
echo | openssl s_client -connect letsencrypt.org:443 2>/dev/null | openssl x509 -noout -ocsp_uri
# (empty — no OCSP URI in the certificate at all)

Sectigo still publishes one; Let's Encrypt no longer does. The operational consequence: if you have ssl_stapling on in nginx for a Let's Encrypt certificate, there is nothing to staple, and nginx will log a stapling warning on every reload forever. Revocation for those certs travels by CRL now, which browsers consume out-of-band (Chrome's CRLSet, Firefox's CRLite) rather than at handshake time.

Certificate Transparency (CT): Since 2018, Chrome requires all new certificates to be logged to public CT logs. This means every certificate issued is publicly auditable. Domain owners can monitor CT logs for unauthorized certificates issued for their domain — this is how security teams catch unauthorized cert issuances before attackers can use them.

The Record Layer: Where Data Actually Gets Encrypted

Once the handshake completes, data flows as TLS records. Each record is independently encrypted with an AEAD (Authenticated Encryption with Associated Data) cipher.

RFC 8446 Appendix B.4 defines five cipher suites, though you will only ever see the first three on the web:

  • TLS_AES_128_GCM_SHA256 (0x1301) — AES-128-GCM, SHA-256 for the key schedule
  • TLS_AES_256_GCM_SHA384 (0x1302) — AES-256-GCM, SHA-384
  • TLS_CHACHA20_POLY1305_SHA256 (0x1303) — ChaCha20 with Poly1305, the fast choice without AES hardware
  • TLS_AES_128_CCM_SHA256 (0x1304) — CCM mode, for constrained/embedded stacks
  • TLS_AES_128_CCM_8_SHA256 (0x1305) — CCM with a truncated 8-octet tag

The two CCM suites exist for IoT-class devices and are not enabled in browsers, which is why "TLS 1.3 has three ciphers" gets repeated. Note also what the suite name no longer includes: in TLS 1.2 the suite named the key exchange and the certificate type too (ECDHE_RSA_WITH_...). In 1.3 those are negotiated by separate extensions, so a suite is just AEAD plus hash.

AEAD is the key insight: a single operation provides both confidentiality (nobody can read it) and integrity (nobody can tamper with it). The "associated data" (the record header) is authenticated but not encrypted, so the record type and length are protected against tampering even though they're transmitted in the clear.

Each record uses a per-record nonce derived by XORing the static IV (from the key schedule) with the sequence number. Since the sequence number increments for every record, no two records share a nonce. Nonce reuse with GCM is catastrophic — it leaks the plaintext — so this design is important.

TLS Record = {
  ContentType: application_data (23),
  LegacyVersion: 0x0303,  // always TLS 1.2 for wire compat
  Length: <n>,
  EncryptedData: AEAD_Encrypt(
    key   = derived from application_traffic_secret,
    nonce = IV XOR sequence_number,
    plaintext = actual content + inner content type,
    aad   = record header
  )
}

Session Resumption: 0-RTT Mode

A full 1-RTT handshake is fast. But reconnecting to the same server (closing and reopening a tab, mobile switching networks) still costs that round trip. TLS 1.3's session resumption cuts this to 0-RTT.

After a successful handshake, the server sends a NewSessionTicket message containing an opaque blob that encodes the resumption_master_secret. The client stores this ticket.

On reconnect, the client includes a pre_shared_key (PSK) extension in its ClientHello and can send early data (the first HTTP request) before the handshake completes:

ClientHello + PSK extension + early_data (0-RTT)
    → ServerHello
    → EncryptedExtensions
    → Finished
Client Finished
⚠️

0-RTT data has no inherent replay protection. A network attacker can record a 0-RTT ClientHello and replay it, and RFC 8446 §8 is explicit that the burden lands on the client, not the server: "clients will not know which, if any, of these mechanisms servers actually implement and hence MUST only send early data which they deem safe to be replayed."

The mechanisms the server can implement are single-use session tickets, a recorded-ClientHello database, or the freshness check in §8.3 — not max_early_data_size, which is only a byte budget in the NewSessionTicket early_data extension and caps how much you may send, not how many times it may arrive. So: idempotent GETs only, never a payment or a form submission. In nginx the knob is ssl_early_data, off by default, and turning it on means auditing every route reachable in that window.

Inspecting TLS in Practice

You don't have to take any of this on faith. Here's how to see it:

Note the echo | on every one of these. s_client reads stdin and holds the connection open waiting for you to type an HTTP request; without redirecting stdin it sits there until the server's idle timeout fires, which looks exactly like a hang.

# Full handshake details: cipher, certificate chain, extensions
echo | openssl s_client -connect github.com:443 -tls1_3 -tlsextdebug 2>&1 | head -80
 
# Subject of the leaf cert
echo | openssl s_client -connect github.com:443 -showcerts 2>/dev/null \
  | openssl x509 -noout -subject -issuer
 
# Check what TLS versions and suites a server supports
nmap --script ssl-enum-ciphers -p 443 github.com
 
# Decode a certificate's SANs and validity window
echo | openssl s_client -connect github.com:443 2>/dev/null \
  | openssl x509 -noout -dates -ext subjectAltName

That last one, run against github.com on 2026-08-03:

notBefore=Jul  3 00:00:00 2026 GMT
notAfter=Sep 30 23:59:59 2026 GMT
X509v3 Subject Alternative Name:
    DNS:github.com, DNS:www.github.com

Two SANs. Not a wildcard, not a dozen hostnames — which is worth knowing before you assume connection coalescing will kick in across a company's subdomains.

In Node.js, you get TLS metadata from the socket:

import tls from 'node:tls';
 
const socket = tls.connect({ host: 'github.com', port: 443, servername: 'github.com' }, () => {
  const cert = socket.getPeerCertificate(true); // true = full chain
  console.log('Cipher:', socket.getCipher());
  console.log('Protocol:', socket.getProtocol());
  console.log('Subject:', cert.subject.CN);
  console.log('Issuer:', cert.issuer.O);
  console.log('Valid until:', cert.valid_to);
  console.log('SANs:', cert.subjectaltname);
 
  socket.end();
});

Actual output, Node 22.22.3, 2026-08-03:

Cipher: {
  name: 'TLS_AES_128_GCM_SHA256',
  standardName: 'TLS_AES_128_GCM_SHA256',
  version: 'TLSv1.3'
}
Protocol: TLSv1.3
Subject: github.com
Issuer: Sectigo Limited
Valid until: Sep 30 23:59:59 2026 GMT
SANs: DNS:github.com, DNS:www.github.com

AES_128, note — not the 256-bit suite you might expect a large site to insist on. Nobody is compromising on security there: 128-bit AES-GCM is the mandatory-to-implement suite, it's what OpenSSL offers first, and it is faster. If you find yourself writing "and the cipher will be TLS_AES_256_GCM_SHA384" in a document, run the probe first.

Pass servername explicitly, incidentally. Node infers SNI from host, but if you ever connect by IP the inference vanishes and a server with virtual hosts will hand you the wrong certificate.

Practical Implications

Knowing TLS internals changes how you build and debug:

Certificate errors are almost never the server's fault. When you see ERR_CERT_AUTHORITY_INVALID, it usually means the chain is broken — either the intermediate cert isn't being served (check with openssl s_client -showcerts), or you're in an environment with a corporate MITM proxy that has its own root CA you haven't trusted.

mTLS is TLS with client certificates. In a standard TLS handshake, only the server authenticates. In mutual TLS (mTLS), the server requests a certificate from the client too. This is the right way to authenticate service-to-service calls — no shared secrets, no API keys, just X.509 certs with automated rotation. Kubernetes service meshes like Istio/Linkerd do this transparently.

HSTS prevents protocol downgrade attacks. An attacker who intercepts your HTTP request before the HTTPS redirect can strip TLS entirely. HSTS (Strict-Transport-Security: max-age=63072000; includeSubDomains; preload) tells browsers to always connect via HTTPS, refusing to downgrade. Browsers maintain a preload list of HSTS domains baked into the binary.

TLS termination architecture matters. In most production setups, TLS terminates at a load balancer (NGINX, HAProxy, AWS ALB), and backend services communicate over plaintext HTTP within the private network. This is fine as long as the network is actually private. For high-security environments, re-encrypting between the load balancer and backends (using mTLS) is worth the operational complexity.

Certificate expiry is still a footgun, and the window is closing. Let's Encrypt certs expire every 90 days today, and the SC-081 staircase takes the industry maximum to 47 days by 2029. Any renewal process that involves a human is already broken; it just hasn't failed yet. Automate with certbot renew on a timer or cert-manager in Kubernetes, and monitor the expiry independently of the renewal — the incidents I've seen were never "renewal failed loudly", they were internal services nobody hit until something else broke and then the cert had been dead for a week.

# Monitor certificate expiry from a cron job (GNU date; use `date -j -f` on macOS)
echo | openssl s_client -connect api.yourservice.com:443 2>/dev/null \
  | openssl x509 -noout -enddate \
  | awk -F= '{print $2}' | xargs -I{} date -d "{}" +%s \
  | xargs -I{} sh -c 'echo $(( ({} - $(date +%s)) / 86400 )) days remaining'

Run against github.com on the day I wrote this, that pipeline printed 58 days remaining. The date -d is GNU-only, which is the usual reason this snippet fails silently on a developer's Mac and works on the server.

The padlock is not magic. Every one of the claims above has a command next to it, and if a number here disagrees with what your terminal says, believe your terminal — the certificate landscape has moved twice in the last eighteen months and will move again.

Comments (0)

No comments yet. Be the first to share your thoughts!

Related Articles

A monotonic stack maintains elements in order and pops when that order breaks — finding the next greater element for every popped value in O(n) total.
AdminAugust 3, 20265 min read
The EventEmitter pattern lets components in the same process react to the same event without being directly coupled — no message broker needed.
AdminAugust 3, 20266 min read
One file to guard every route — plus the Next.js 16 rename that moves it off the Edge runtime, the request-vs-response header trap that leaks user IDs to the browser, and the CVE that explains why this can never be your only auth layer.
AdminAugust 3, 20268 min read