How TLS Works: Certificates, Handshakes, and the Trust Chain Behind Every Secure Connection
A deep dive into TLS 1.3: certificate chain validation, ECDHE key exchange, session resumption with PSK and 0-RTT, certificate transparency, and production considerations for rotation, pinning, and mTLS.
Every HTTPS request you make goes through a protocol that accomplishes three things before a single byte of application data is exchanged: it verifies the server is who it claims to be, it negotiates encryption keys without ever transmitting them over the wire, and it establishes session parameters that protect every subsequent byte. That protocol is TLS. Most engineers use it daily and understand it at the level of “certificate expired” or “add the intermediate cert.” This article goes deeper.
The Trust Problem TLS Solves
The core problem is this: you want to connect to api.example.com and exchange sensitive data. How do you know the machine at the other end of that TCP connection is actually api.example.com and not an attacker who hijacked DNS or is sitting on your network path?
Symmetric encryption is cheap and fast, but you need to agree on a shared key first, and you cannot send the key in plaintext. Asymmetric encryption solves the key exchange problem, but now you need to trust that the public key you received actually belongs to who you think it does.
TLS solves both: it uses asymmetric cryptography to authenticate identity and to bootstrap a shared secret, then switches to symmetric encryption for the actual data transfer. The authentication part relies on a chain of trust rooted in Certificate Authorities (CAs) whose public keys are pre-installed in your operating system and browser.
Certificates and the Chain of Trust
A TLS certificate is an X.509 structure containing a public key, metadata about the entity it belongs to (the subject), a validity period, and a digital signature from a CA. When you receive a certificate, you verify that signature. If the signing CA is one you trust, you trust the certificate.
The problem is operational: you cannot pre-install a certificate from every CA that might ever issue a certificate. Instead, CAs form a hierarchy.
Root CAs are the trust anchors. Their self-signed certificates are embedded in OS and browser trust stores. There are roughly 150 root CAs trusted by major browsers. Root CA private keys are kept offline in hardware security modules, in physically secured facilities, often with multi-person authorization requirements for any signing operation.
Intermediate CAs are signed by root CAs. Day-to-day certificate issuance happens through intermediates. If an intermediate is compromised, it can be revoked without touching the root. A typical chain looks like:
Root CA (self-signed, offline)
└── Intermediate CA (signed by root)
└── Leaf certificate (signed by intermediate, your domain)
When a client receives your server’s certificate, it must build a path from the leaf to a trusted root and verify every signature in the chain. The client has the root CA’s public key locally. It uses that to verify the intermediate’s signature. It uses the intermediate’s public key to verify the leaf certificate’s signature. If all signatures are valid and the chain terminates at a trusted root, authentication passes.
Why servers should send the intermediate. Root CA certificates are pre-installed, but intermediate certificates are not. If your server only sends the leaf certificate, the client cannot build the chain and will reject the connection. This is one of the most common TLS misconfiguration mistakes. Always configure your server to send the full chain: leaf plus all intermediates, in order, excluding the root.
OCSP and Revocation
Certificates have expiry dates, but sometimes a certificate needs to be invalidated before it expires: the private key is compromised, the business dissolves, the domain changes hands. Revocation is how this works.
The old mechanism, Certificate Revocation Lists (CRLs), involved downloading a list of revoked serial numbers from the CA. Lists grow large and clients rarely checked them on every connection.
OCSP (Online Certificate Status Protocol) is the modern approach. The CA runs an OCSP responder at a URL embedded in the certificate. Clients can query it with the certificate serial number and get a signed “good”, “revoked”, or “unknown” response. But this introduces a live network dependency on every TLS handshake, adds latency, and leaks browsing behavior to the CA.
OCSP Stapling resolves this. The server fetches the OCSP response proactively and caches it. During the TLS handshake, the server includes (“staples”) the signed OCSP response alongside its certificate. The client gets revocation status without making a separate network request. OCSP responses are signed by the CA, so the client can verify freshness and authenticity.
Configure this in nginx:
ssl_stapling on;
ssl_stapling_verify on;
ssl_trusted_certificate /path/to/chain.pem;
resolver 8.8.8.8 valid=300s;
The TLS 1.3 Handshake
TLS 1.3 (RFC 8446) reduced the handshake from two round trips to one, dropped weak cipher suites, and made forward secrecy mandatory. Understanding exactly what happens during the handshake tells you why each step exists.
Step 1: ClientHello
The client sends a ClientHello containing:
- The maximum TLS version it supports
- A random 32-byte nonce (
client_random) - A list of supported cipher suites (in TLS 1.3, all include AEAD and provide forward secrecy)
- Supported key exchange groups (e.g., X25519, P-256)
- Key Share extension: the client’s public key(s) for its preferred group(s)
- Optional session ticket for resumption
The key share is new in TLS 1.3. By sending the public key in the first message, the client enables the server to complete key exchange immediately, without a second round trip.
Step 2: ServerHello and Key Derivation
The server responds with:
- The selected cipher suite and key exchange group
- The server’s ephemeral public key for that group
- Its own nonce (
server_random)
Both sides now have what they need for ECDHE (Elliptic Curve Diffie-Hellman Ephemeral) key exchange. Each side generated an ephemeral key pair. The shared secret is derived from the client’s private key and the server’s public key, which equals the product of the server’s private key and the client’s public key. This is the Diffie-Hellman property.
// Conceptual illustration only — do not implement this yourself
// Real key exchange uses WebCrypto or a TLS library
async function deriveSharedSecret(
myPrivateKey: CryptoKey,
theirPublicKey: CryptoKey
): Promise<ArrayBuffer> {
return crypto.subtle.deriveBits(
{ name: "ECDH", public: theirPublicKey },
myPrivateKey,
256
);
}
// The shared secret feeds into HKDF to produce:
// - handshake_traffic_secret (encrypts the rest of the handshake)
// - master_secret (seeds application traffic keys)
async function deriveTrafficKeys(
sharedSecret: ArrayBuffer,
helloHash: ArrayBuffer
): Promise<{ clientKey: CryptoKey; serverKey: CryptoKey }> {
const earlySecret = await hkdfExtract(new Uint8Array(32), sharedSecret);
const handshakeSecret = await hkdfExpand(earlySecret, "tls13 derived", helloHash, 32);
const clientKey = await hkdfExpand(handshakeSecret, "tls13 c hs traffic", helloHash, 32);
const serverKey = await hkdfExpand(handshakeSecret, "tls13 s hs traffic", helloHash, 32);
return {
clientKey: await importAesGcmKey(clientKey),
serverKey: await importAesGcmKey(serverKey),
};
}
Everything from this point in the handshake is encrypted.
Step 3: Server Authentication
Still within the same flight, the server sends:
- EncryptedExtensions: additional parameters that required the shared key to protect
- Certificate: the server’s certificate chain (encrypted now)
- CertificateVerify: a signature over the entire handshake transcript using the server’s private key
- Finished: an HMAC over the handshake transcript using the derived handshake traffic key
CertificateVerify is why TLS authentication works. The server proves it holds the private key corresponding to the public key in the certificate by signing something the attacker cannot predict: the full handshake transcript, including both random nonces. An attacker cannot replay a certificate they intercepted because the signature would not match the new nonces.
Step 4: Client Finished
The client verifies the certificate chain, checks CertificateVerify, sends its own Finished message, and both sides derive the application traffic keys. The handshake is complete in one round trip.
For comparison, TLS 1.2 required two round trips before application data could flow. TLS 1.3 achieves this reduction by combining the key exchange and cipher negotiation into the first flight.
Session Resumption: PSK and 0-RTT
Opening a new TLS session for every connection is expensive. TLS 1.3 supports two resumption mechanisms.
Pre-Shared Keys (PSK)
After a session completes, the server can issue a NewSessionTicket containing a PSK (an encrypted blob the server can later decrypt to recover session parameters). The client stores this ticket and presents it in the ClientHello of the next connection.
If the server recognizes the PSK, it can skip certificate verification and jump straight to key derivation. The handshake still takes one round trip, but the computational overhead is lower: no asymmetric operations for authentication.
0-RTT Early Data
With a valid PSK, the client can send application data in the very first message, before the handshake completes. This is called 0-RTT (“zero round trip time”) or early data.
The tradeoff is significant.
Replay vulnerability. 0-RTT data is sent before the server has contributed a fresh nonce to the key derivation. This means an attacker who captures the first flight can replay it. If the server processes 0-RTT data from a replayed ClientHello, it may execute the request twice. TLS 1.3 requires servers to implement replay mitigation (typically a bounded cache of seen PSK identifiers), but the spec acknowledges this protection is imperfect.
Forward secrecy is weakened. The PSK-derived key does not benefit from a fresh ephemeral key exchange for the early data. If the PSK is somehow compromised later, the 0-RTT data can be decrypted retrospectively.
// Node.js TLS server: accepting but not trusting 0-RTT data for state-changing requests
import * as tls from "tls";
import * as fs from "fs";
import * as http2 from "http2";
const server = http2.createSecureServer({
key: fs.readFileSync("/etc/ssl/private/server.key"),
cert: fs.readFileSync("/etc/ssl/certs/server-chain.pem"),
allowHTTP1: true,
sessionTimeout: 300,
// Allow session tickets for PSK resumption
sessionTickets: true,
});
server.on("request", (req, res) => {
// earlyData is available in TLS 1.3 sessions
const socket = req.socket as tls.TLSSocket;
const isEarlyData = (socket as any).isSessionReused?.() === true;
// Never trust 0-RTT for mutations
if (isEarlyData && req.method !== "GET" && req.method !== "HEAD") {
res.writeHead(425); // 425 Too Early
res.end("Early data not accepted for this method");
return;
}
// Handle normally
res.writeHead(200);
res.end("OK");
});
The HTTP 425 Too Early status code exists precisely for this: tell the client to retry after the handshake completes. Use 0-RTT only for idempotent, replay-safe requests.
Certificate Transparency
Even a properly signed certificate can be fraudulently issued. A CA can be coerced, compromised, or make an operational mistake and sign a certificate for a domain it should not. This happened with DigiNotar (2011), Comodo (2011), and several others.
Certificate Transparency (CT, RFC 6962) is a public audit log system. Every CA that issues publicly-trusted certificates must submit them to at least two CT logs before issuance. The log returns a Signed Certificate Timestamp (SCT) that proves the certificate was logged. Browsers require SCTs to trust a certificate: no SCT, no HTTPS padlock.
CT logs are append-only Merkle trees. A browser or monitoring service can verify:
- The certificate was submitted before issuance (timestamp check)
- The log has not been tampered with (Merkle inclusion proofs)
- The certificate appears in the public record
Domain owners can run or subscribe to CT monitoring services (crt.sh is public). If someone fraudulently obtains a certificate for your domain, you will see it in the CT logs, often within minutes. This does not prevent issuance, but it makes it detectable and attributable.
// Querying crt.sh for certificates issued to a domain
async function getCertificatesForDomain(domain: string): Promise<CertRecord[]> {
const url = `https://crt.sh/?q=${encodeURIComponent(domain)}&output=json`;
const response = await fetch(url);
if (!response.ok) {
throw new Error(`crt.sh query failed: ${response.status}`);
}
const records = await response.json() as CertRecord[];
// Flag any certificates not issued by expected CAs
const trustedIssuers = new Set([
"Let's Encrypt Authority X3",
"DigiCert TLS RSA SHA256 2020 CA1",
// add your expected issuers
]);
return records.filter((cert) => {
const isExpected = trustedIssuers.has(cert.issuer_name);
if (!isExpected) {
console.warn(`Unexpected issuer for ${domain}: ${cert.issuer_name}`);
}
return true; // return all, caller decides what to alert on
});
}
interface CertRecord {
id: number;
issuer_name: string;
common_name: string;
name_value: string;
not_before: string;
not_after: string;
}
Tradeoffs
| Configuration | Pros | Cons | Use When |
|---|---|---|---|
| TLS 1.3 only | Smallest attack surface, mandatory forward secrecy, faster handshake | Breaks clients that only support TLS 1.2 (rare, but legacy enterprise tooling) | New services with controlled clients |
| TLS 1.2 + 1.3 | Wide compatibility | Exposes older cipher suites if misconfigured, higher ops burden | Public APIs with unknown client population |
| 0-RTT enabled | Saves one round trip for returning clients | Replay attack surface on non-idempotent endpoints | Read-heavy APIs after careful endpoint audit |
| OCSP Stapling | No client-side revocation latency, no CA privacy leak | Server must refresh OCSP responses before expiry; stapler failures cause handshake delay | Every production server, should be default |
| Certificate Pinning (client) | Defeats rogue CA issuance, stops MITM in high-value clients | Pin rotation is operationally painful; wrong pins = total outage | Mobile apps with long release cycles |
| mTLS (mutual) | Both sides authenticate; eliminates credential-based service auth | Certificate lifecycle for every service; PKI overhead scales with service count | Service-to-service in zero-trust networks |
| Short-lived certs (24h) | Revocation becomes unnecessary; exposure window minimal | Requires automated renewal infrastructure; any renewal failure = outage | Internal services where you control the PKI |
Production Considerations
Certificate Rotation Without Downtime
The most common cause of TLS-related outages is a certificate expiring while the team was watching the wrong dashboard. The fix is automation, not better humans.
Use ACME (the protocol behind Let’s Encrypt) for automatic issuance and renewal. For services deployed to Kubernetes, cert-manager handles the full lifecycle: requesting, renewing, and injecting certificates into secrets. Configure renewals to happen at 60-70% of the certificate lifetime, not 90%, so there is buffer for retries.
// cert-manager CertificateRequest watcher pattern
// Alert when a certificate's renewalTime is within 7 days and not yet renewed
import * as k8s from "@kubernetes/client-node";
const kc = new k8s.KubeConfig();
kc.loadFromDefault();
const customApi = kc.makeApiClient(k8s.CustomObjectsApi);
async function checkCertificateExpiry(namespace: string): Promise<void> {
const { body } = await customApi.listNamespacedCustomObject(
"cert-manager.io",
"v1",
namespace,
"certificates"
) as { body: { items: CertManagerCertificate[] } };
const now = Date.now();
const sevenDays = 7 * 24 * 60 * 60 * 1000;
for (const cert of body.items) {
const notAfter = new Date(cert.status?.notAfter ?? "").getTime();
const daysRemaining = Math.floor((notAfter - now) / (24 * 60 * 60 * 1000));
if (notAfter - now < sevenDays) {
console.error(
`ALERT: Certificate ${cert.metadata.name} in ${namespace} expires in ${daysRemaining} days`
);
}
}
}
interface CertManagerCertificate {
metadata: { name: string; namespace: string };
status?: { notAfter: string; renewalTime: string };
}
mTLS in Service-to-Service Communication
In a zero-trust network, you cannot assume that traffic reaching your service originated from a legitimate caller just because it arrived on an internal network. mTLS (mutual TLS) requires both sides to present and verify certificates.
The operational challenge is the PKI. You need a CA that can issue certificates to each service, a mechanism to rotate those certificates automatically, and consistent enforcement across services.
Service meshes (Istio, Linkerd) handle this transparently by injecting sidecar proxies that terminate mTLS at the network layer. Your application code sees plain HTTP from the sidecar. The mesh manages certificate issuance via SPIFFE (Secure Production Identity Framework For Everyone), assigning workload identities tied to Kubernetes service accounts.
If you are not using a mesh, you can implement mTLS directly. The key is treating the certificate as the identity token, not a credential you pass in headers:
import * as https from "https";
import * as fs from "fs";
import * as tls from "tls";
// Server: require client certificate
const server = https.createServer(
{
key: fs.readFileSync("/certs/server.key"),
cert: fs.readFileSync("/certs/server.crt"),
ca: fs.readFileSync("/certs/internal-ca.crt"),
requestCert: true,
rejectUnauthorized: true, // reject connections without valid client cert
},
(req, res) => {
const socket = req.socket as tls.TLSSocket;
const clientCert = socket.getPeerCertificate();
if (!clientCert || !socket.authorized) {
res.writeHead(401);
res.end("Client certificate required");
return;
}
// Extract service identity from certificate subject
const serviceIdentity = clientCert.subject.CN;
console.log(`Authenticated service: ${serviceIdentity}`);
// Enforce service-level authorization
const allowedCallers = new Set(["payment-service", "order-service"]);
if (!allowedCallers.has(serviceIdentity)) {
res.writeHead(403);
res.end("Service not authorized");
return;
}
res.writeHead(200);
res.end(JSON.stringify({ status: "ok", caller: serviceIdentity }));
}
);
// Client: present certificate when calling internal services
function createMtlsClient(serviceName: string): typeof https {
const agent = new https.Agent({
key: fs.readFileSync(`/certs/${serviceName}.key`),
cert: fs.readFileSync(`/certs/${serviceName}.crt`),
ca: fs.readFileSync("/certs/internal-ca.crt"),
});
return { ...https, globalAgent: agent };
}
Certificate Pinning
Pinning tells a client to only accept a specific certificate or public key, regardless of whether a CA signed it. This removes the rogue-CA attack surface entirely for known endpoints.
The operational risk is severe: if you pin the leaf certificate and need to rotate it, every pinned client must be updated before the old certificate expires. If you pin the intermediate or root public key instead, you get protection against rogue leaf issuance while retaining flexibility.
For mobile applications, the HPKP (HTTP Public Key Pinning) HTTP header approach was deprecated and removed from browsers. The current approach is to embed pins in the application binary and implement the validation in code, using something like TrustKit on iOS or the OkHttp CertificatePinner on Android. For server-to-server, pin in your HTTP client configuration:
import * as https from "https";
import * as tls from "tls";
import * as crypto from "crypto";
// Pin the expected public key fingerprint (SHA-256 of the SubjectPublicKeyInfo)
const EXPECTED_PIN = "sha256/AbCdEfGhIjKlMnOpQrStUvWxYz0123456789Ab=";
const agent = new https.Agent({
checkServerIdentity: (host, cert) => {
// Run default hostname verification first
const defaultError = tls.checkServerIdentity(host, cert);
if (defaultError) return defaultError;
// Then verify the public key pin
const pubkey = cert.pubkey as Buffer;
const fingerprint = crypto
.createHash("sha256")
.update(pubkey)
.digest("base64");
const pin = `sha256/${fingerprint}`;
if (pin !== EXPECTED_PIN) {
return new Error(`Certificate pin mismatch for ${host}: got ${pin}`);
}
return undefined; // pin matched, no error
},
});
Prefer pinning the CA or intermediate public key over the leaf. Track expiry of pinned keys in your alerting infrastructure.
The Cipher Suite Question
TLS 1.3 reduces cipher suite negotiation to three choices, all of which use AEAD (Authenticated Encryption with Associated Data) and provide forward secrecy:
TLS_AES_128_GCM_SHA256TLS_AES_256_GCM_SHA384TLS_CHACHA20_POLY1305_SHA256
ChaCha20-Poly1305 performs better than AES-GCM on hardware without AES acceleration instructions, which matters for mobile clients and lower-powered edge nodes. On modern server hardware with AES-NI, AES-GCM is faster. Modern TLS libraries negotiate this automatically when both sides advertise both cipher suites.
In TLS 1.2, the cipher suite list was a minefield: dozens of options, many weak (RC4, 3DES, export-grade), anonymous key exchange modes (no authentication), and CBC modes vulnerable to padding oracle attacks if implementations were not careful. TLS 1.3 solved this by deprecating everything except the three suites above for new sessions.
Closing Thought
TLS is one of the most consequential pieces of infrastructure on the internet, and most engineers interact with it only when something breaks. The certificate expired, the chain is incomplete, the handshake is slow, the revocation check is blocking. Each of those symptoms has a structural cause rooted in the protocol. Understanding the chain from root CA to ECDHE key derivation to session ticket issuance gives you the mental model to diagnose problems correctly and to make informed decisions about where to trade security for performance, or accept operational complexity for stronger guarantees.
The fundamentals have not changed since the RFC dropped: authentication via certificate chains, confidentiality via symmetric encryption, key exchange via asymmetric math. What has changed is how cleanly TLS 1.3 implements them.
More in System Design
How Amazon Aurora Works Internally: The Log-Is-the-Database Architecture, Quorum Writes, and Storage-Compute Separation Behind Cloud-Native SQL
A deep dive into Aurora's storage-compute separation, the log-is-the-database design that pushes redo log processing to storage nodes, quorum-based replication across 6 copies in 3 AZs, protection group architecture, fast cloning, Serverless v2 scaling mechanics, and honest tradeoffs versus RDS, self-managed PostgreSQL, CockroachDB, and Cloud Spanner.
How CockroachDB Works Internally: Distributed SQL, Raft Consensus Per Range, and the Architecture Behind Serializable Transactions at Global Scale
A deep dive into CockroachDB's internals: the 512MB range-based data model with automatic splitting, per-range Raft replication, MVCC timestamp ordering with hybrid logical clocks, DistSQL physical planning, serializable transactions with timestamp refreshes, closed timestamps for follower reads, online schema changes using multi-version schema descriptors, and production considerations for hotspots and write amplification.
How Container Runtimes Work Internally: Namespaces, Cgroups, and the OCI Stack From Docker Run to Process Isolation
Containers are not virtual machines and they are not magic. They are a thin composition of Linux kernel primitives: namespaces, cgroups, and a layered filesystem. This article traces exactly what happens between docker run and a running process, covering the OCI spec, containerd, runc, overlay filesystems, and container networking at the kernel level.
How YugabyteDB Works Internally: DocDB Storage, Tablet Splitting, and the Dual API Architecture That Scales PostgreSQL Horizontally
A deep dive into YugabyteDB internals covering the DocDB storage engine built on RocksDB LSM trees, per-tablet Raft consensus, YSQL and YCQL dual query layers, distributed MVCC with hybrid logical clocks, automatic tablet splitting and rebalancing, xCluster multi-region replication, and production considerations for hotspots, connection pooling, and schema design.