Skip to content
Software Atlas

TLS & HTTPS

How HTTPS keeps traffic private, tamper-proof and authenticated: just enough crypto, Diffie-Hellman, the TLS 1.3 handshake, certificates and chains of trust, forward secrecy, mTLS, and how to debug it all in Go and Node.

⏱ ~45 min read14 sections7 interactive widgetsGo · JavaScript
FoundationsPractitionerStaff-level
Foundations§01

The 30-second version

In one sentenceHTTPS is ordinary HTTP sent through a TLS tunnel. TLS makes three promises: nobody in between can read it, nobody can change it, and you're talking to the real server.

When your laptop talks to a website, the bytes don't fly straight there. They hop through the café's Wi-Fi router, your internet provider, and a dozen other machines. Any of them can read every byte of a plain HTTP request, and change it before passing it on. Like a postcard: every postal worker who handles it can read it, or scribble on it.

TLS (Transport Layer Security; its old name was SSL) fixes that. HTTPS is just HTTP inside a TLS connection, on Port 443 instead of 80. The HTTP itself is unchanged: same methods, headers and bodies.

Try it. Below, you press "Pay" in an online shop while an attacker, Eve, sits on the same café Wi-Fi. Switch between http:// and https://, and try each attack.

What does the café Wi-Fi see?
You press “Pay” on a shopping site. Pick the protocol and what the attacker on the Wi-Fi tries.
Eve just watches the bytes go by.
💻 your laptop sends
POST /pay HTTP/1.1
Host: shop.example
Cookie: session=8f2a91c4e7
Content-Type: application/x-www-form-urlencoded
card=4242424242424242&amount=10
🕵️ Eve on the Wi-Fiplain text
POST /pay HTTP/1.1
Host: shop.example
Cookie: session=8f2a91c4e7
Content-Type: application/x-www-form-urlencoded
card=4242424242424242&amount=10
🏪 shop.example receives
POST /pay HTTP/1.1
Host: shop.example
Cookie: session=8f2a91c4e7
Content-Type: application/x-www-form-urlencoded
card=4242424242424242&amount=10
✕ Eve now has your session cookie (she can log in as you) and your card number. Plain HTTP hides nothing.

What HTTPS does not hide

Notice what Eve could still see with HTTPS: the server's IP address, the site name shop.example, when you connected, and roughly how many bytes went each way. TLS protects the contents, not the fact that you're talking to someone. (A newer feature, ECH, also hides the site name; see §04.)

It also doesn't make the server itself trustworthy. A scam site can have a perfectly valid padlock. The padlock means "your connection goes privately to the owner of this name", not "this name is honest".

The plan for this page

§02–03 give you just enough cryptography to follow the rest: two kinds of keys, and how two strangers agree on a secret in public. §04 walks through the handshake message by message. §05–06 cover certificates, the ID cards, and every way checking one can fail. Then come forward secrecy, real Go and Node code, mTLS, debugging, and production lessons.

Foundations§02

Just enough crypto

In one sentenceTLS uses slow key-pair crypto for a few milliseconds at the start, to agree on a shared key and prove who the server is. Then it switches to fast shared-key crypto for every byte of real data.

You don't need the math to understand TLS. You need four tools and what each one is for. (The algorithms themselves are covered in Hashing & Encryption: symmetric encryption and key pairs and signatures.)

Tool 1: symmetric encryption, one shared key

Symmetric encryption uses one key to both lock and unlock. Feed in Plaintext and the key, get Ciphertext. Feed in the ciphertext and the same key, get the plaintext back.

It is very fast: a laptop encrypts several gigabytes per second with AES (the standard symmetric Cipher), because modern CPUs have special instructions for it. The catch is the obvious one. Both sides need the same key, and you can't just send it over the network, because Eve would see it.

Tool 2: asymmetric crypto, a key pair

Public-key cryptography gives each party a pair of keys that belong together: a public key you can hand to anyone, and a private key you never show anybody. The server's private key lives on the server and nowhere else.

A key pair can be used in two directions:

  • Lock with the public key, unlock with the private key (encryption). Anyone can lock a message for the server, but only the server can open it. Some algorithms, notably RSA, can do this.
  • Stamp with the private key, check with the public key (signing). Only the server can make the stamp, but anyone can check it. That's tool 3 below.

Try both directions:

One key pair, two directions
Lock with the public key and unlock with the private one, or stamp with the private key and check with the public one.
💻 you (sender)
message: card=4242…
server's PUBLIC key 🔓 (anyone may have it)
📡 on the wire
nothing yet
🏪 server (receiver)
server's PRIVATE key 🔐 (never leaves)
🕵️ Eve
watching
Encrypt direction. You want to send something only the server can read. You have the server's public key: everyone does.
1 / 4

Key pairs are roughly a thousand times slower than shared keys (a rough order of magnitude, not a benchmark), so nobody encrypts a whole web page with them. Modern TLS uses them for two short jobs:

  1. Agreeing on a shared key in public. This is called Key exchange. §03 shows how it can possibly work.
  2. Proving identity with signatures (tool 3).

Old TLS versions also used the "lock with the public key" direction to send the session key itself to the server. That turned out to be a bad idea; §07 shows why.

Two kinds of keys, and the job each does in TLS
🔑 Symmetric: one shared key
hello—🔑→9f3ce1…—🔑→hello
Same key locks and unlocks. Very fast (gigabytes per second). Problem: how do two strangers get the same key without sending it where Eve can see it?
In TLS: encrypts all your actual data (AES-GCM, ChaCha20-Poly1305).
🗝️ Asymmetric: a key pair
🔓 public: give to anyone🔐 private: never leaves the server
Solves the stranger problem, but about 1,000× slower (rough order of magnitude). Two uses: key exchange (agree on a secret) and signatures (prove who you are).
In TLS: X25519 key exchange (§03), and the certificate's key signs the handshake (§04).
TLS uses the slow key pair only at the start, to agree on and authenticate a fast shared key. Every byte after that uses the shared key.

Tool 3: digital signatures, a seal only you can make

A Digital signature is a short value computed from some data with a private key. Anyone with the matching public key can check it. If the check passes, two things are proven: the signature was made by whoever holds the private key, and the data hasn't changed since.

A signature doesn't hide anything. It's the wax seal from §01, plus the signet ring that only one person owns. In TLS the server signs the handshake to prove it owns its certificate (§04), and certificate authorities sign certificates (§05).

Tool 4: tamper-proof tags

Encryption alone doesn't stop tampering: Eve can't read the ciphertext, but she could still flip bits in it. So TLS also attaches a tag to every chunk of data, computed with the shared key. Change one bit and the tag no longer matches, and the receiver rejects the chunk. That's what happened in the "tamper" attack in §01.

A tag made with a shared key is called a MAC. Modern ciphers do encryption and tagging in a single step, called an AEAD. The two you'll see in TLS are AES-GCM and ChaCha20-Poly1305. Under the hood, TLS also uses a Hash function (SHA-256 or SHA-384) to fingerprint messages and to turn one shared secret into several keys.

How the tools fit together in TLS

JobToolWhen
Agree on a fresh shared secretkey exchange (X25519, a kind of Diffie-Hellman)once, in the handshake
Prove the server is shop.examplecertificate + signatureonce, in the handshake
Encrypt and tag every byte of dataAEAD with the shared key (AES-GCM, ChaCha20-Poly1305)the whole connection
Foundations§03

Agreeing on a secret in public

In one sentenceEach side keeps a secret, sends a public mix of it, and combines the other side's mix with its own secret. Both end up with the same key; the eavesdropper can't.

It sounds impossible: two strangers, every message readable by Eve, and at the end they share a secret that Eve doesn't know. The trick, published by Whitfield Diffie and Martin Hellman in 1976, is called Diffie-Hellman. The classic way to picture it uses paint.

Diffie-Hellman with paint
Agree on a secret colour while Eve watches every message. Step through it.
👩 Alice
public yellow
agreed in public
📡 public wireEve sees all of this
public yellow
👨 Bob
public yellow
agreed in public
step 1 / 6
Alice and Bob have never met, and every message between them goes over a wire Eve can see. First they agree, in public, on a common paint colour: yellow. Eve sees it too, and that's fine.

The whole idea rests on one property: mixing is easy, un-mixing is practically impossible. Eve sees yellow, orange and green go by. To make the final brown, she'd need Alice's red or Bob's blue, and she can't separate them out of the mixtures.

The same thing with real (small) numbers

Real Diffie-Hellman replaces paint with arithmetic that's easy one way and hard to reverse. The operation is "multiply a number by itself many times, then keep only the remainder". "Remainder" is written mod: 17 mod 5 = 2, because 17 = 3×5 + 2. It works like a clock: on a 12-hour clock, 15 o'clock is 3, so 15 mod 12 = 3.

Public: p = 23 and g = 5 (the "yellow"). Here's a worked example with tiny numbers:

Alicepublic wire (Eve sees)Bob
secreta = 4b = 3
public mixA = 5⁴ mod 23 = 625 mod 23 = 4A = 4 →
public mix← B = 10B = 5³ mod 23 = 125 mod 23 = 10
shared secretB^a = 10⁴ mod 23 = 18A^b = 4³ mod 23 = 18

Both get 18. Eve knows p, g, A = 4 and B = 10. To get 18 she'd need to find a from "5 to the what, mod 23, is 4?". With 23 she could just try every number. With the numbers real TLS uses (hundreds of digits long), trying them all would take longer than the age of the universe.

Why do both sides get the same answer? Because (g^b)^a and (g^a)^b are both g^(a×b), and the mod doesn't change that. You can check the table yourself with JavaScript's big-integer type:

const p = 23n, g = 5n, a = 4n, b = 3n;   // n = BigInt: whole numbers of any size
const A = g ** a % p, B = g ** b % p;    // ** = power, % = remainder (mod)
console.log(A, B);                       // 4n 10n   (what goes over the wire)
console.log(B ** a % p, A ** b % p);     // 18n 18n  (same secret on both sides)

What TLS actually uses: X25519

Modern TLS uses the same idea with different math: elliptic curves (another kind of easy-one-way, hard-to-reverse math; you don't need its details). The keys are only 32 bytes and the computation takes microseconds. The most common choice is a curve called X25519. TLS also uses a fresh key pair for every single connection and throws it away afterwards. That combination has a name, ECDHE (Elliptic-Curve Diffie-Hellman, Ephemeral), and the "ephemeral" part matters a lot (§07).

Both Go and Node ship X25519 in their standard libraries. Two key pairs, one exchange, the same secret on both sides:

X25519 key exchange
package main

import (
    "bytes"
    "crypto/ecdh"
    "crypto/rand"
    "fmt"
)

func main() {
    alice, _ := ecdh.X25519().GenerateKey(rand.Reader) // secret a (+ public A)
    bob, _ := ecdh.X25519().GenerateKey(rand.Reader)   // secret b (+ public B)

    // Only the PUBLIC keys are exchanged. Each side mixes in its own private key.
    s1, _ := alice.ECDH(bob.PublicKey())
    s2, _ := bob.ECDH(alice.PublicKey())

    fmt.Println(bytes.Equal(s1, s2), len(s1)) // true 32 ← same 32-byte secret
}
Practitioner§04

The handshake, step by step

In one sentenceIn TLS 1.3 the client sends its key-exchange value in the very first message. One round trip later both sides share a key, the server has proved its identity, and the request can go.

A TLS Handshake is the short conversation at the start of a connection that gets the three promises in place. It runs on top of an already-open TCP connection. (TCP's own handshake, which opens that connection, is covered in TCP & UDP.) It has to do three things:

  1. Agree on algorithms (which cipher, which key-exchange method).
  2. Agree on a shared secret with Diffie-Hellman (§03).
  3. Prove the server's identity, so the secret isn't shared with an impostor.

Step through it. The bottom row shows what an Eavesdropper can read at each step. Then switch to TLS 1.2 and count the round trips.

The TLS 1.3 handshake
Step with → / ←. 🔓 = readable by anyone on the network, 🔒 = encrypted.
💻 client
TCP connected to shop.example:443
📡 on the wire
nothing sent yet
🏪 server
waiting
🕵️ what an eavesdropper can read
nothing yet
The TCP connection is open (that's its own round trip; see TCP & UDP). Nothing is encrypted yet. Watch the wire column: 🔓 orange = anyone can read it, 🔒 green = encrypted.
1 / 9

The messages, in one table

MessageDirectionEncrypted?What it does
ClientHello→norandom value, supported versions and ciphers, key share (public value A), server name, app protocols
ServerHello←nochosen cipher, server's key share (public value B). Both now compute the shared secret.
EncryptedExtensions←yesthe remaining choices, e.g. "we'll speak HTTP/2"
Certificate←yesthe server's certificate chain (§05)
CertificateVerify←yesa signature over the whole handshake so far, made with the certificate's private key
Finished← and →yesa MAC over the handshake, proving both sides saw the same messages and hold the same keys

Why CertificateVerify is the key step

A certificate is public. Anyone can download shop.example's certificate and send it. What an impostor can't do is sign with the matching private key.

So the server signs a hash of the Handshake transcript: every handshake message so far, including both key shares and both random values. Three things follow:

  • It owns the certificate. Only the private key's owner could make that signature.
  • Nobody swapped the key shares. If a man-in-the-middle had replaced B with her own value, the transcript the client hashed wouldn't match what the server signed. This is what defeats the attack from the end of §03.
  • It's fresh. Each side's random value (a Random / nonce) is in the transcript, so a signature recorded from an old handshake is useless in a new one.

Here's the man-in-the-middle attack from §03, first against bare Diffie-Hellman, then against TLS with CertificateVerify:

Man-in-the-middle vs CertificateVerify
Eve swaps the key shares. Without a signature she wins; with TLS's signed transcript she's caught.
💻 client
secret a 🤫
sends public A →
🕵️ Eve in the middle
grabs A, makes her own secret e
🏪 server
receives E (thinks it's from the client)
Bare Diffie-Hellman, no signatures. Eve sits on the path. She catches the client's A and sends her own public value E to the server instead.
1 / 4

What the eavesdropper sees: records

After the hellos, everything travels as TLS records: a 5-byte header, then up to 16 KB of encrypted data with its tamper-proof tag. The header is readable: 17 means "application data", 03 03 is an old version number (TLS 1.2) that TLS 1.3 keeps sending so old network equipment doesn't choke, and the next two bytes give the length. That's the 17 03 03 … you saw in the widgets. The type and length are visible; the content isn't.

What's inside the ClientHello: SNI, ALPN, ECH

Two fields in the ClientHello solve practical problems, and one newer feature hides one of them.

SNI. One server, one IP address, often hosts hundreds of sites (think of a hosting company or a CDN). Before it can send a certificate, it needs to know which site you want, and the HTTP Host header hasn't arrived yet; it'll be inside the encrypted tunnel. So the ClientHello carries the hostname in plain text. That's how Eve saw shop.example in §01.

ECH. ECH encrypts the sensitive parts of the ClientHello, including SNI, using a key the server publishes in DNS. Observers see only a shared "front" name, such as the CDN's. Recent Chrome and Firefox versions and big CDNs support it, but most servers don't yet, so assume the site name is visible.

ALPN. The client lists the application protocols it speaks (h2 for HTTP/2, http/1.1); the server picks one in EncryptedExtensions. That's how browser and server agree on HTTP/2 without spending an extra round trip to negotiate. The details of HTTP/2 itself live in HTTP.

TLS 1.2 vs 1.3: where the round trip went

In TLS 1.2 the client first asks "which algorithms?", and only after the answer starts the key exchange: two round trips. TLS 1.3 (2018) dropped every weak option, leaving so few choices that the client can just guess the key-exchange method (usually X25519, or the X25519 + ML-KEM hybrid from §11) and send its key share right away.

Round trips before your first request byte leaves (one 100 ms round trip each)
TLS 1.2
TCP
TLS rt 1
TLS rt 2
300 ms
TLS 1.3
TCP
TLS
200 ms → request sent
1.3 0-RTT
TCP
100 ms → request rides with the hello
Approximate. Each coloured block is one 100 ms round trip; the thin green mark is the moment the request leaves. TCP always costs one, unless you use QUIC, the transport under HTTP/3 that merges the transport and TLS handshakes (see HTTP). TLS 1.3 halves TLS's cost, and resumption with 0-RTT removes it entirely, at a price (§11).

On a phone connection with 100 ms between client and server, that's 100 ms saved on every new connection, before any of your code runs. TLS 1.3 also encrypts the certificate and removes old, breakable algorithms (including the RSA key exchange you'll meet in §07).

You can watch a real handshake with curl -v. Every message from the table above appears, in order (output trimmed):

$ curl -v https://localhost:8441/
* ALPN: curl offers h2,http/1.1
* TLSv1.3 (OUT), TLS handshake, Client hello (1):
* TLSv1.3 (IN), TLS handshake, Server hello (2):
* TLSv1.3 (IN), TLS handshake, Encrypted Extensions (8):
* TLSv1.3 (IN), TLS handshake, Certificate (11):
* TLSv1.3 (IN), TLS handshake, CERT verify (15):
* TLSv1.3 (IN), TLS handshake, Finished (20):
* TLSv1.3 (OUT), TLS handshake, Finished (20):
* SSL connection using TLSv1.3 / TLS_AES_128_GCM_SHA256 / X25519 / id-ecPublicKey
* ALPN: server accepted h2

With a newer curl built on OpenSSL 3.5+, the key-exchange part reads X25519MLKEM768 (the post-quantum hybrid, §11) instead of X25519. The SSL connection using line names the version, the Cipher suite (AES-128-GCM for data, SHA-256 for deriving keys), the key exchange (X25519) and the type of the certificate's key. §10 shows how to read the rest of the output.

Practitioner§05

Certificates and the chain of trust

In one sentenceA certificate says "this public key belongs to shop.example", signed by someone your computer already trusts. The chain of signatures is how a stranger's key becomes believable.

In the handshake, the server proved it owns some private key (CertificateVerify, §04). But how does the client know that key belongs to shop.example and not to Eve? It can't have met every server in advance. Instead it trusts a small number of organizations whose job is to vouch for servers: certificate authorities.

What's inside a certificate

A Certificate is a small file in a format called X.509. Here's the interesting part of a real one, printed with openssl x509 -text:

$ openssl x509 -in shop.pem -noout -text
        Issuer: CN = Atlas Demo Intermediate        # who signed it
        Validity
            Not Before: Oct 30 10:36:15 2026 GMT    # valid from…
            Not After : Nov 29 10:36:15 2026 GMT    # …until
        Subject: CN = shop.example                   # who it's about
        Subject Public Key Info:
            Public Key Algorithm: id-ecPublicKey     # the server's PUBLIC key
        X509v3 extensions:
            X509v3 Basic Constraints:
                CA:FALSE                             # may NOT sign other certs
            X509v3 Subject Alternative Name:
                DNS:shop.example, DNS:www.shop.example   # names it's valid for
    Signature Algorithm: ecdsa-with-SHA256
    Signature Value: 30:45:02:21:00:99:85:…          # the issuer's signature over all of the above

The fields that matter day to day:

  • Subject Alternative Name (SAN): the list of hostnames this certificate is valid for. Clients check the name they connected to against this list. (The old CN field, Common Name, is just a display name now; modern clients ignore it for this check.)
  • Validity: start and end dates. A certificate is rejected outside them.
  • Public key: the key whose private half must sign CertificateVerify.
  • Issuer and signature: who vouches for all this, and their proof.
  • CA:FALSE / CA:TRUE: whether this certificate may sign other certificates.

The private key is not in the certificate. It lives in a separate file that never leaves the server. Both are usually stored as PEM file text files.

The chain: leaf, intermediate, root

The CA doesn't sign shop.example with its most important key. There are usually three levels:

A certificate chain
Root CA: "Example Root R1"signed by: itself · key offlineIntermediate: "Example CA E5"signed by: Root R1 · CA:TRUELeaf: "shop.example"signed by: E5 · CA:FALSEsigned bysigned by💻 client's trust store~150 roots, ships with OS/browser🏪 server sendsin the Certificate message:1. leaf2. intermediate(root not needed: client has it)
Each certificate is signed by the one above it. The client already has the root (in its trust store); the server sends the leaf and the intermediate.
  • The Root certificate signs itself. Nobody vouches for it; it's trusted because it comes pre-installed in your operating system's or browser's Trust store. Its private key sits offline in a vault, used a few times a year.
  • The Intermediate certificate is signed by the root and does the daily work of signing websites. If it's ever compromised, the CA revokes just that one and makes a new one, without having to replace a root in billions of devices.
  • The Leaf certificate is your server's certificate.

The client walks this Certificate chain from the bottom up: check the leaf's signature with the intermediate's public key, check the intermediate's signature with the root's public key, and the root is in the trust store, so stop. The server must send the intermediate; forgetting it is one of the most common TLS bugs (§06).

How a CA decides you own a domain

A CA won't sign "this key belongs to shop.example" just because you ask. For an ordinary website certificate it checks one thing: do you control the domain? It does that with a challenge you can only pass if you control it:

  • HTTP-01: "Put this random token at http://shop.example/.well-known/acme-challenge/<token>." The CA fetches it.
  • DNS-01: "Put this token in a DNS record of type TXT (a free-text note attached to a name) under _acme-challenge.shop.example." Needed for wildcard certificates (*.shop.example), and works for servers that aren't reachable from the internet.

Let's Encrypt and ACME: certificates on autopilot

Until 2015, certificates cost money and were renewed by hand once a year, so they were regularly forgotten. Let's Encrypt made them free and automatic with a protocol called ACME. A small client on your server does this, typically every 60 days:

  1. Generate a key pair, and ask the CA for a certificate for shop.example.
  2. The CA replies with a challenge (HTTP-01 or DNS-01).
  3. The client publishes the token; the CA checks it.
  4. The CA signs the certificate and returns it, with the intermediate.
  5. The client saves fullchain.pem (leaf + intermediate) and privkey.pem, and reloads the web server.

Common ACME clients: certbot, the built-in support in Caddy and Traefik, cert-manager in Kubernetes, and in Go, the autocert package (§08). With automation in place, short lifetimes cost nothing. That's the point: a stolen key is only useful until the certificate expires.

Practitioner§06

How validation fails (and what the errors mean)

In one sentenceA client accepts a certificate only if five checks pass: the chain builds, it ends at a trusted root, every signature is valid, today is within every validity window, and the hostname is listed. Each failure has its own error message, and learning to read them saves hours.

When the certificate arrives in the handshake (§04), the client runs its checks before sending a single byte of your request:

  1. Build the chain. Starting from the leaf, find the certificate that signed it, then the one that signed that, up to a root.
  2. Trust the root. The root must be in the client's Trust store.
  3. Verify every signature along the chain.
  4. Check dates. "Now" must be between each certificate's start and end date. "Now" means the client's clock.
  5. Check the hostname. The name you connected to must be in the leaf's SAN list.

Each scenario below breaks one link. The Go, Node and curl error strings are real: they were captured from Go 1.24, Node 22 and curl against test servers. (Go prints the x509: … part shown here after a tls: failed to verify certificate: prefix.) The Chrome codes are the ones its warning page shows. Notice how often Go just says "unknown authority".

Break the chain
Pick a failure. Watch the client run its checks in order, see which one fails, and the exact error each tool prints.
you typed: https://shop.exampleclient's today: 14 Mar 2026
Root CA✓ in trust store
subjectExample Root R1
issuerExample Root R1 (itself)
valid to2040
↑ signed by ✓ signature checks out
Intermediate
subjectExample CA E5
issuerExample Root R1
valid to2029
↑ signed by ✓ signature checks out
Leaf (the server)
subjectshop.example
issuerExample CA E5
valid to12 Jun 2026
SANshop.example, www.shop.example
client's checks
  1. ✓ 1. Build a chain up to a root
  2. ✓ 2. Root is in my trust store
  3. ✓ 3. Every signature is valid
  4. ✓ 4. Today is inside every validity window
  5. ✓ 5. Hostname is listed in the leaf's SAN
Every link checks out: the leaf is signed by the intermediate, the intermediate by a root that is in the trust store, all dates are valid and the hostname is listed. The padlock appears.

Three lessons from the widget

"Unknown authority" covers several different problems. In Go, a Self-signed certificate certificate, a missing intermediate and a private company CA all produce x509: certificate signed by unknown authority. The question to ask is "which link of the chain can't the client find?" Node is the most specific: DEPTH_ZERO_SELF_SIGNED_CERT (self-signed), UNABLE_TO_VERIFY_LEAF_SIGNATURE (missing intermediate) and UNABLE_TO_GET_ISSUER_CERT_LOCALLY (no trusted root). curl recognizes self-signed, but reports both a missing intermediate and an unknown root as unable to get local issuer certificate.

"Works in my browser" proves little. Browsers fetch missing intermediates on their own, cache ones they've seen, and use their own trust store. Go, Node and curl don't fetch. A server with a broken chain can look fine in Chrome and fail for every API client. Test with curl or openssl s_client (§10), never just a browser.

The client's clock matters. If the client thinks it's 2019, every modern certificate is "not yet valid". This is called Clock skew. Check the time on the failing machine before you blame the server.

Revocation: cancelling a certificate early

What if a server's private key leaks and its certificate still has two months to run? The CA can mark the certificate as cancelled, called Revocation. The hard part is getting clients to notice.

  • CRLs (Certificate Revocation Lists): the CA publishes a list of cancelled certificates. Lists get big, and clients download them rarely.
  • OCSP: the client asks the CA "is certificate #1234 still good?" during the handshake. That's slow, it tells the CA which sites you visit, and if the CA doesn't answer, browsers usually carry on anyway ("soft-fail"). An attacker who can block the CA's answer defeats it.
  • OCSP stapling: the server fetches a signed, recent OCSP answer itself and attaches it to the handshake. Faster and private, but optional, so attackers can just leave it off.

In practice, browsers rely on their own compact lists of important revocations, and the industry is moving away from OCSP; Let's Encrypt has already stopped offering it. The real answer to revocation is short-lived certificates: if a certificate expires in a few weeks anyway, a leaked key is only useful for a few weeks. That's why §05's lifetimes keep shrinking.

Practitioner§07

Forward secrecy

In one sentenceWith throwaway keys for every connection, stealing the server's long-term private key later can't unlock traffic recorded earlier. That's Forward secrecy, and TLS 1.3 always has it.

A patient attacker doesn't need to break your encryption today. Disk space is cheap, so they can record encrypted traffic now and wait for the day they get hold of the server's private key. Keys leak in boring ways: a backup on the wrong storage bucket, a memory-disclosure bug (2014's Heartbleed let attackers read server memory, private keys included, on a large share of the web's HTTPS servers), an old disk sold without wiping, a legal order.

So the question is: does the server's long-term key unlock past sessions? It depends on how the session key was agreed.

Record now, steal the key later
Same attacker, two kinds of key exchange. Step through both.
🎙️ Eve's recording
ClientHello, ServerHello, Certificate
🧰 what Eve has
nothing secret yet
📜 outcome
not yet
2025. Old-style TLS 1.2 with RSA key exchange: the client invents the session secret itself and sends it to the server encrypted with the public key from the server's certificate.
1 / 4

Why the two designs differ

RSA key exchange (allowed in TLS 1.2, removed in 1.3): the client invents the session secret and sends it locked with the public key from the certificate (the "lock with the public key, unlock with the private key" direction from §02). The certificate's private key is therefore the one thing needed to recover any session secret, from any connection, ever. One key leak exposes all of history.

Ephemeral Diffie-Hellman (ECDHE, the only option in TLS 1.3): the session secret comes from Diffie-Hellman (§03) between two Ephemeral key secrets, a and b, made fresh for this connection and deleted right after the handshake. The certificate's private key only signs the handshake to prove identity (CertificateVerify, §04); it never encrypts anything. Steal it later and you can sign things, but there's nothing to decrypt with it.

RSA key exchange (TLS 1.2 only)ECDHE (TLS 1.2 option, TLS 1.3 always)
Session secret comes fromclient's random value, encrypted with the cert keyDiffie-Hellman between throwaway a and b
Certificate key is used todecrypt the session secretsign the handshake
Key stolen later → past trafficreadablestill secret
Key stolen later → future trafficimpersonation possibleimpersonation possible (until the cert is replaced)
Practitioner§08

TLS in Go and Node

In one sentenceServing HTTPS takes two files (the full chain and the private key). Calling HTTPS services takes nothing, unless the server uses a private CA, and then you add that CA instead of switching checks off.

Serving HTTPS

The good news: Go's and Node's TLS defaults are sensible. You hand over a certificate and a key, and you get TLS 1.2 and 1.3 with modern ciphers and forward secrecy.

HTTPS server
package main

import (
    "crypto/tls"
    "fmt"
    "log"
    "net/http"
    "time"
)

func main() {
    mux := http.NewServeMux()
    mux.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
        // r.TLS is set for HTTPS requests: version, cipher, client certs (§09)…
        fmt.Fprintf(w, "hello over %s\n", tls.VersionName(r.TLS.Version))
    })

    srv := &http.Server{
        Addr:              ":8443",
        Handler:           mux,
        ReadHeaderTimeout: 10 * time.Second,
        TLSConfig: &tls.Config{
            MinVersion: tls.VersionTLS12, // ← refuse TLS 1.0/1.1 (Go's default too; explicit is clearer)
        },
    }
    // fullchain.pem = leaf + intermediate(s)   privkey.pem = the private key
    log.Fatal(srv.ListenAndServeTLS("fullchain.pem", "privkey.pem"))
}

For a quick one-off there's also http.ListenAndServeTLS(":8443", "fullchain.pem", "privkey.pem", mux), but a http.Server value lets you set timeouts and TLS options.

Getting certificates automatically in Go: autocert

The golang.org/x/crypto/acme/autocert package is an official (but not standard-library) ACME client (§05); add it with go get golang.org/x/crypto/acme/autocert. It asks Let's Encrypt for certificates the first time a hostname is requested, stores them, and renews them before they expire.

main.go
import "golang.org/x/crypto/acme/autocert"

m := &autocert.Manager{
    Prompt:     autocert.AcceptTOS,                                   // accept Let's Encrypt's terms
    HostPolicy: autocert.HostWhitelist("example.com", "www.example.com"), // ← only these names!
    Cache:      autocert.DirCache("/var/lib/myapp/certs"),            // keep certs across restarts
}

// Port 80 answers the HTTP-01 challenge and redirects everything else to https.
go func() { log.Fatal(http.ListenAndServe(":80", m.HTTPHandler(nil))) }()

srv := &http.Server{Addr: ":443", Handler: mux, TLSConfig: m.TLSConfig()}
log.Fatal(srv.ListenAndServeTLS("", "")) // "" "": certificates come from the Manager, not files

Two things to get right: set HostPolicy (otherwise anyone can point a domain at your server and make it request certificates, burning your Let's Encrypt rate limit), and set a persistent Cache (otherwise every restart requests new certificates). Behind a load balancer that terminates TLS (§09), the load balancer gets the certificates instead, and your app doesn't need any of this.

Calling a server that uses a private CA

Calling public HTTPS sites needs no configuration: the client uses the operating system's trust store. Internal services are different. They often use certificates from your company's own CA, whose root isn't in any public trust store, and you get §06's "unknown authority". The fix is to add that root to what the client trusts.

trust a private CA
func newClient(caFile string) (*http.Client, error) {
    pool, err := x509.SystemCertPool() // start from the OS trust store…
    if err != nil {
        return nil, err
    }
    pemBytes, err := os.ReadFile(caFile)
    if err != nil {
        return nil, err
    }
    if !pool.AppendCertsFromPEM(pemBytes) { // …and ADD the company root
        return nil, errors.New("no certificates found in " + caFile)
    }
    tr := http.DefaultTransport.(*http.Transport).Clone() // ← keeps HTTP/2, proxy settings, timeouts
    tr.TLSClientConfig = &tls.Config{RootCAs: pool, MinVersion: tls.VersionTLS12}
    return &http.Client{Transport: tr, Timeout: 10 * time.Second}, nil
}

The two lines you must never ship

Every language has a switch that turns off certificate checking. It makes the error go away, which is why it gets copied from Stack Overflow.

✕ Disables authentication
tr := &http.Transport{
    TLSClientConfig: &tls.Config{
        InsecureSkipVerify: true, // ✕ accepts ANY certificate
    },
}
// Node equivalents:
//   https.get(url, { rejectUnauthorized: false })
//   NODE_TLS_REJECT_UNAUTHORIZED=0 node app.js
✓ Trust the right CA instead
pool, _ := x509.SystemCertPool() // handle err in real code
if !pool.AppendCertsFromPEM(companyRootPEM) {
    log.Fatal("no certificates in company root file")
}
// tr = a clone of http.DefaultTransport, as in newClient above
tr.TLSClientConfig = &tls.Config{
    RootCAs: pool, // ✓ verification stays on
}
// Node: NODE_EXTRA_CA_CERTS=company-root.pem
// For local dev: mkcert installs a local CA

With verification off, the connection is still encrypted, but to whoever answered. Any Man-in-the-middle presents their own certificate, and your client happily encrypts your API keys to them. That's the locked box handed to a stranger from §01. Node at least prints a warning for the environment variable ("Setting the NODE_TLS_REJECT_UNAUTHORIZED environment variable to '0' makes TLS connections and HTTPS requests insecure…"). Go prints nothing.

Staff-level§09

mTLS and TLS termination

In one sentenceNormal TLS proves the server's identity. mTLS makes the client show a certificate too, so services can prove to each other who's calling, without passwords or API keys.

mTLS: both sides show an ID

Inside a company, service A calls service B thousands of times a second. B needs to know the caller really is A. Shared API keys work, but they leak, are hard to rotate, and are just strings. With mTLS, every service gets its own certificate from an internal CA. During the handshake the server sends a CertificateRequest, and the client answers with its own Certificate and CertificateVerify, exactly mirroring what the server does (§04).

The result: before any request arrives, B knows cryptographically which service is calling. It's the standard for Microservice traffic, and gRPC services commonly use it.

mTLS server: require a client certificate
caPEM, err := os.ReadFile("internal-ca.pem")
if err != nil {
    log.Fatal(err)
}
clientCAs := x509.NewCertPool() // only certs from OUR CA, not public ones
if !clientCAs.AppendCertsFromPEM(caPEM) { // empty pool = every client rejected
    log.Fatal("no certificates found in internal-ca.pem")
}

srv := &http.Server{
    Addr: ":8443",
    TLSConfig: &tls.Config{
        ClientAuth: tls.RequireAndVerifyClientCert, // ← no valid client cert, no connection
        ClientCAs:  clientCAs,
        MinVersion: tls.VersionTLS13,
    },
    Handler: http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
        peer := r.TLS.PeerCertificates[0] // the caller's verified leaf certificate
        fmt.Fprintf(w, "hello %s (%v)\n", peer.Subject.CommonName, peer.URIs)
        // → hello billing-service ([spiffe://example.org/billing])
    }),
}
log.Fatal(srv.ListenAndServeTLS("server-fullchain.pem", "server-key.pem"))

The client side adds its own certificate and key. In Go, load them with tls.LoadX509KeyPair("client-fullchain.pem", "client-key.pem") and put the result in tls.Config.Certificates; in Node pass cert and key to https.get or an https.Agent. A client that shows no certificate is turned away during the handshake: Go reports remote error: tls: certificate required, Node ERR_SSL_TLSV13_ALERT_CERTIFICATE_REQUIRED.

TLS termination: where does the tunnel end?

Most production setups put a Load balancer or Reverse proxy in front of the app. To route by URL path, add headers or cache, it has to read the HTTP, which means it must decrypt the TLS. Decrypting at the proxy is called TLS termination. The question is what happens on the second hop.

Where TLS ends: three ways to put a load balancer in front
Termination
💻 client
🔒 TLS
⚖️ load balancer
holds the cert
plain HTTP
🖥️ app
LB decrypts; app gets plain HTTP. Simple, but anyone on the internal network can read traffic.
Re-encryption
💻 client
🔒 TLS #1
⚖️ load balancer
holds the cert
🔒 TLS #2
🖥️ app
LB decrypts, inspects, then opens a new TLS connection to the app. The usual choice today.
Passthrough
💻 client
🔒 one TLS connection, untouched
⚖️ LB
TCP only
🖥️ app
LB forwards encrypted bytes without decrypting (it only sees SNI). The app holds the cert; the LB can't route by URL.
Solid green = TLS, dotted orange = unencrypted. The load balancer details live in Load Balancing.
  • Termination (plain HTTP behind). Simplest: certificates live in one place, apps don't handle TLS. But traffic inside your network is readable by anything that gets in there, and many compliance rules forbid it. Once common, now mostly discouraged.
  • Re-encryption. The load balancer decrypts, does its routing, and opens a new TLS connection to the app, often with mTLS. Internal traffic stays encrypted. The default for most cloud setups today.
  • Passthrough. The load balancer never decrypts. It reads only the SNI from the ClientHello to choose a backend and forwards raw bytes. The app holds the certificate. Use it when the app must see the client's certificate itself or the proxy must not see the data, at the cost of no HTTP-level routing.

When TLS ends at a proxy, your app no longer sees the TLS details directly. Proxies pass them along in headers such as X-Forwarded-Proto: https (so the app knows the original request was HTTPS, e.g. for redirects and secure cookies) and X-Forwarded-For (the client's IP). Only trust those headers when they come from your own proxy. Algorithms, health checks and L4 vs L7 balancing are covered in Load Balancing.

Practitioner§10

Debugging TLS

In one sentenceWhen TLS fails, look at what the server actually sends: openssl s_client shows the chain, curl -v shows the handshake, and the exact error message tells you which of §06's checks failed.

openssl s_client: the TLS Swiss army knife

openssl s_client opens a TLS connection and prints everything about it. The flag people forget is -servername: without it, no SNI is sent, and a server hosting many sites may hand you the wrong certificate (or a default one), sending you off to debug a problem that doesn't exist.

# echo | … closes stdin so the command exits right after the handshake
$ echo | openssl s_client -connect shop.example:443 -servername shop.example

Here's what to look at in the output (trimmed, from a test server whose certificate happens to be named ok):

Certificate chain
 0 s:CN = ok                                  # 0 = leaf. s: = subject ("ok" is just our test cert's name)
   i:CN = Atlas Demo Intermediate             #           i: = issuer (who signed it)
   v:NotBefore: Oct 30 10:31:24 2026 GMT; NotAfter: Jan 28 10:31:24 2027 GMT
 1 s:CN = Atlas Demo Intermediate             # 1 = intermediate: its subject must equal
   i:CN = Atlas Demo Root CA                  #     the issuer of the cert above it
---
Server Temp Key: X25519, 253 bits             # the ephemeral key exchange (§03)
New, TLSv1.3, Cipher is TLS_AES_128_GCM_SHA256
Verify return code: 0 (ok)                    # ← the verdict. Anything but 0 is a problem

Read the chain like a ladder: each certificate's i: (issuer) should be the next one's s: (subject). If the server sends only entry 0, the intermediate is missing, and you'll see:

verify error:num=20:unable to get local issuer certificate
verify error:num=21:unable to verify the first certificate
Verify return code: 21 (unable to verify the first certificate)

Useful variations:

# Show every certificate the server sends, as PEM
echo | openssl s_client -connect shop.example:443 -servername shop.example -showcerts

# Just the leaf's names and dates (pipe the output into openssl x509)
echo | openssl s_client -connect shop.example:443 -servername shop.example 2>/dev/null \
  | openssl x509 -noout -subject -issuer -dates -ext subjectAltName

# Does the server accept TLS 1.3? (fails fast if not)
echo | openssl s_client -connect shop.example:443 -servername shop.example -tls1_3

# Test against a private CA instead of the system trust store
echo | openssl s_client -connect billing.internal:443 -servername billing.internal -CAfile internal-ca.pem

curl -v: the handshake as a story

curl -v prints each handshake message (as in §04), then a certificate summary. The last block is the useful one:

$ curl -v https://localhost:8441/
* SSL connection using TLSv1.3 / TLS_AES_128_GCM_SHA256 / X25519 / id-ecPublicKey
* ALPN: server accepted h2
* Server certificate:
*  subject: CN=ok                                                # our test cert's name
*  start date: Oct 30 10:31:24 2026 GMT
*  expire date: Jan 28 10:31:24 2027 GMT
*  subjectAltName: host "localhost" matched cert's "localhost"   # ← hostname check (§06, check 5)
*  issuer: CN=Atlas Demo Intermediate
*  SSL certificate verify ok.

Add --cacert internal-ca.pem to test a private CA, or --resolve shop.example:443:10.0.0.7 to send the request to one specific server IP while still using the right hostname for SNI and the certificate check. That second one is great for testing one machine behind a load balancer.

Common errors and what really causes them

ErrorWhich check failedUsual real cause
Go x509: certificate signed by unknown authority / curl unable to get local issuer certificate or self-signed certificatechain or trustserver sends no intermediate (Node: UNABLE_TO_VERIFY_LEAF_SIGNATURE); or private CA not added, or a corporate proxy re-signing traffic with its own root (Node: UNABLE_TO_GET_ISSUER_CERT_LOCALLY); or a self-signed cert (Node: DEPTH_ZERO_SELF_SIGNED_CERT)
Go x509: certificate is valid for a, b, not c / Node ERR_TLS_CERT_ALTNAME_INVALIDhostnameconnecting by IP or an internal alias; cert missing a name; wrong cert on a shared IP (check -servername)
Go x509: certificate has expired or is not yet valid / Node CERT_HAS_EXPIREDdatesrenewal failed, or renewed but the server wasn't reloaded; or the client's clock is wrong
Go x509: cannot validate certificate for 10.0.0.7 because it doesn't contain any IP SANshostnameconnecting by IP to a cert with only DNS names. Use the name, or set ServerName
remote error: tls: certificate required (Go) / ERR_SSL_TLSV13_ALERT_CERTIFICATE_REQUIRED (Node)mTLSthe server wants a client certificate (§09) and none was sent
tls: handshake failure, no shared cipher, protocol versionalgorithmsone side is very old (TLS 1.0 only) or configured with an overly narrow MinVersion/cipher list
Works in the browser, fails in codechainmissing intermediate: browsers fill the gap, Go/Node/curl don't (§06)

Seeing inside your own encrypted traffic

Wireshark and tcpdump capture packets, but TLS traffic is, by design, unreadable to them. For debugging your own client, you can make it write its session keys to a file, and Wireshark will use that file to decrypt the capture:

  • curl and browsers: set the Environment variable SSLKEYLOGFILE=/tmp/keys.log.
  • Node: node --tls-keylog=/tmp/keys.log app.js.
  • Go: set KeyLogWriter in tls.Config to an open file.

Then in Wireshark: Preferences → Protocols → TLS → "(Pre)-Master-Secret log filename". Never leave this on in production: that file decrypts everything.

Staff-level§11

TLS in production

In one sentenceIn production, TLS rarely fails because of cryptography. It fails because a certificate expired, a handshake happens on every request, or a well-meant setting (pinning, 0-RTT, HSTS) outlives the assumption behind it.

1. Handshakes cost a round trip and some CPU. Reuse connections.

A full TLS 1.3 handshake costs one Round-trip time plus a signature on the server and a few signature checks on the client: a millisecond or so of CPU, roughly. Negligible once; very noticeable if your service makes a new connection for every request to a server 50 ms away.

The biggest win isn't TLS tuning at all; it's reusing connections (Keep-alive). In Go, create one http.Client and share it, and always read and close response bodies so the connection goes back to the pool. In Node, fetch and the default https.globalAgent keep connections alive (Node ≥ 19), but a new https.Agent() does not unless you pass keepAlive: true. You can measure where time goes with Go's httptrace:

var start time.Time
trace := &httptrace.ClientTrace{
    TLSHandshakeStart: func() { start = time.Now() },
    TLSHandshakeDone: func(cs tls.ConnectionState, err error) {
        log.Printf("TLS handshake: %v, resumed=%v, alpn=%q",
            time.Since(start), cs.DidResume, cs.NegotiatedProtocol)
    },
}
req = req.WithContext(httptrace.WithClientTrace(req.Context(), trace))

If you see a handshake log line on every request, connection reuse is broken. Look for unclosed bodies, a new http.Client per request, or DisableKeepAlives.

2. Session resumption: skipping the certificate on reconnect

After a handshake, a TLS 1.3 server hands the client a ticket: an encrypted blob from which the server can later recover the session's secret. Next time, the client presents the ticket and both sides skip sending and checking certificates. That's Session resumption. It still takes one round trip, but less data and much less CPU.

Two production gotchas:

  • Go clients don't resume unless you ask. Servers issue tickets automatically, but an http.Client only stores them if its tls.Config has a session cache: ClientSessionCache: tls.NewLRUClientSessionCache(256). (We checked: without it, DidResume stays false on every new connection.)
  • A fleet behind a load balancer. Each Go server makes its own random ticket key, so a ticket from server 1 means nothing to server 2, and resumption quietly fails half the time. Sharing keys (Config.SetSessionTicketKeys) fixes that, but then you own rotating them: a long-lived shared ticket key undoes forward secrecy for resumed sessions (§07).

3. 0-RTT: faster, and replayable

With a ticket, a TLS 1.3 client can send its first request inside the very first flight, encrypted with a key derived from the ticket, before the server has replied. That's 0-RTT ("zero round-trip" early data): the request leaves immediately.

The catch: early data has no fresh contribution from the server, so an attacker who recorded it can send it again, and the server may accept it again. That's a Replay attack. A replayed GET /products is harmless. A replayed POST /transfer moves money twice.

So 0-RTT is only safe for requests that are harmless to repeat (Idempotent reads). Servers that accept it typically allow only safe methods and mark early requests (HTTP has an Early-Data: 1 header and a 425 Too Early status for this). In practice you'll mostly meet 0-RTT at CDNs and in HTTP/3; Go's crypto/tls only supports early data for QUIC, and Node's tls module doesn't expose it.

4. Certificate expiry is the classic outage

TLS outages at well-known companies are overwhelmingly expired certificates: a manual renewal someone forgot, an automated one that broke silently months ago, or an internal CA whose root nobody remembered expires in ten years. Everything fails at once, at a time nobody chose. What works:

  • Automate renewal (ACME, §05) and renew at two-thirds of the lifetime, not the last day.
  • Monitor what clients actually see, not what's on disk: probe every endpoint and alert below ~14 days left. Quick check: openssl x509 -checkend 1209600 -noout -in cert.pem exits non-zero if the cert expires within 14 days (1,209,600 seconds).
  • Reload without restarting. A renewed file on disk does nothing until the process loads it. In Go, tls.Config.GetCertificate is called on every handshake, so it can serve whatever certificate was loaded most recently:
var current atomic.Pointer[tls.Certificate] // swapped by a reload goroutine or signal handler

func reload() error {
    c, err := tls.LoadX509KeyPair("fullchain.pem", "privkey.pem")
    if err != nil {
        return err // keep serving the old cert
    }
    current.Store(&c)
    return nil
}

if err := reload(); err != nil { // load once at startup, or GetCertificate returns nil
    log.Fatal(err)
}
cfg := &tls.Config{
    GetCertificate: func(*tls.ClientHelloInfo) (*tls.Certificate, error) {
        return current.Load(), nil // ← called on every handshake
    },
}

5. HSTS: never fall back to http://

Even with HTTPS everywhere, a user who types shop.example starts with a plain http:// request, which an attacker on the Wi-Fi can intercept before your redirect to HTTPS happens. HSTS closes that gap: send

Strict-Transport-Security: max-age=63072000; includeSubDomains; preload

and browsers will use HTTPS for your domain for the next two years without ever trying http://. With preload you can submit your domain to a list built into browsers, protecting even the very first visit. Be careful: includeSubDomains also covers that old intranet host on plain HTTP, and preloading is hard to undo. Start with a short max-age and raise it.

6. Pinning: mostly a mistake now

Certificate pinning means the client accepts only one specific key or certificate for a server, rather than any valid CA-signed one. It defends against a rogue CA, but it turns every key rotation into a potential outage: rotate the key before every client has updated, and those clients can't connect at all. Browsers deprecated their pinning mechanism (HPKP) in 2018 and had removed it by 2020, after sites locked their own users out. Today, Certificate Transparency (§05) catches rogue certificates without that risk. If a mobile app must pin, pin to the CA's key or a set including backup keys, never one leaf certificate, and have a remote kill switch.

7. Post-quantum key exchange is already on

A large quantum computer could someday break the elliptic-curve math behind X25519, and traffic recorded today could be decrypted then (§07's "harvest now, decrypt later"). The defense is Post-quantum cryptography key exchange, deployed now as a hybrid: X25519 and ML-KEM-768 combined, so the connection stays safe as long as either one holds.

  • Go 1.24+ offers X25519MLKEM768 by default (go doc crypto/tls.Config.CurvePreferences: "From Go 1.24, the default includes the X25519MLKEM768 hybrid post-quantum key exchange"). Disable with GODEBUG=tlsmlkem=0 if needed.
  • Node gets it from its bundled OpenSSL. The Node 22.22 we tested bundles OpenSSL 3.5, and a default Node client negotiated the hybrid with a Go 1.24 server. Older builds with OpenSSL 3.0 don't support it.
  • Browsers: recent Chrome, Edge and Firefox enable it by default, and major CDNs accept it.

The production catch: the hybrid key share is over a kilobyte, so the ClientHello no longer fits in one Packet. Old middleboxes and badly written firewalls that assumed it would can drop these connections, and servers that don't support the hybrid send a HelloRetryRequest (§04). If clients on one network start failing after an upgrade, this is a suspect.

Practitioner§12

Pitfalls

#1

Turning verification off to make an error go away

InsecureSkipVerify: true, rejectUnauthorized: false, NODE_TLS_REJECT_UNAUTHORIZED=0, curl -k. Each one keeps the encryption but drops authentication, so any man-in-the-middle can read everything. They tend to start as "just for testing" and end up in production. Fix the trust instead: add the right root (§08).

#2

Serving the leaf without its intermediate

The server is configured with cert.pem instead of fullchain.pem. Browsers cope; Go, Node, curl and mobile apps fail with "unknown authority" or "unable to verify the first certificate" (§06). Check with openssl s_client -showcerts: you should see at least two certificates.

#3

Renewal that renews the file but not the process

Certbot replaced the files on disk at 3 a.m.; nginx or your Go service still serves the old certificate from memory, and it expires next week. Reload after renewal (nginx -s reload, GetCertificate, setSecureContext, §11), and monitor the certificate as clients see it.

#4

Node's ca option replaces the built-in roots

https.get(url, { ca: companyRoot }) means "trust only this root". Internal calls work; calls to any public API fail with UNABLE_TO_GET_ISSUER_CERT_LOCALLY. Use NODE_EXTRA_CA_CERTS, or [...tls.rootCertificates, companyRoot].

#5

A custom Go Transport silently loses HTTP/2

&http.Transport{TLSClientConfig: cfg} turns off HTTP/2 unless you also set ForceAttemptHTTP2: true, and drops the default proxy and timeout settings. Clone http.DefaultTransport and change only TLSClientConfig (§08).

#6

Connecting by IP address or a nickname

https://10.0.0.7/ or https://billing/ fails the hostname check because the certificate lists billing.internal. Use the name on the certificate, or set ServerName (Go) / servername (Node) to it while dialing the IP.

#7

Private keys in git, images or logs

The private key is the server's identity. Anyone with it can impersonate the server until the certificate expires or is revoked. Keep keys out of repositories and container images; mount them from a secret store, give them 0600 permissions, and rotate immediately if one leaks.

#8

Expecting HTTPS to hide which site you visit

The IP, the SNI hostname (without ECH) and traffic sizes are visible (§01, §04). Don't put secrets in hostnames (customer-acme-secret.example.com), and remember every hostname in a public certificate is published in Certificate Transparency logs (§05).

#9

Accepting 0-RTT for requests that change things

If your CDN or server enables early data, a recorded POST can be replayed (§11). Allow 0-RTT only for safe, idempotent requests, or turn it off.

#10

Plain HTTP behind the load balancer, forever

"The internal network is trusted" stops being true the first time anything inside it is compromised. Re-encrypt from the load balancer to the app, ideally with mTLS (§09), and make sure apps behind a terminating proxy use X-Forwarded-Proto only when it comes from that proxy.

Practitioner§13

Check yourself

Answer before you click. Each explanation points back to the section that covers it.

QUESTION 1 / 7
You log in to your bank over HTTPS on café Wi-Fi. What can the café owner learn?
QUESTION 2 / 7
Round trips before the first request byte is sent?
Fresh connection, no resumption: TCP + TLS 1.3
QUESTION 3 / 7
What does this client protect you from?
tr := &http.Transport{
    TLSClientConfig: &tls.Config{InsecureSkipVerify: true},
}
client := &http.Client{Transport: tr}
QUESTION 4 / 7
Your Go service fails with “x509: certificate signed by unknown authority”, but the same URL loads fine in Chrome. Most likely cause?
QUESTION 5 / 7
Eve records your TLS 1.3 traffic today. In two years she steals the server's private key. Can she decrypt the recording?
QUESTION 6 / 7
Why does this fail against https://api.github.com?
import https from "node:https";
import { readFileSync } from "node:fs";

https.get("https://api.github.com/", {
  ca: readFileSync("company-root.pem"),
}, (res) => console.log(res.statusCode));
QUESTION 7 / 7
Which request is safe to send as TLS 1.3 0-RTT early data?
Answered 0 / 7
Foundations§14

Cheat sheet

🔒
Three promisesConfidentiality (locked box), integrity (wax seal), authentication (ID card). Encryption without authentication is worthless.
🗝️
Two kinds of keysKey pairs set up the connection (key exchange + signatures). A fast shared key (AES-GCM / ChaCha20) carries the data.
🎨
Diffie-HellmanEach side mixes a secret into a public value; both derive the same key; Eve can't un-mix. TLS 1.3 uses X25519, fresh per connection.
⚡
TLS 1.3 = 1 round tripKey share in ClientHello → ServerHello → encrypted Certificate, CertificateVerify, Finished. TLS 1.2 needed 2.
🪪
Chain of trustLeaf ← intermediate ← root in the trust store. The server sends leaf + intermediate (fullchain.pem).
✅
Five checksChain builds · root trusted · signatures valid · dates valid (client clock!) · hostname in SAN.
⏩
Forward secrecyEphemeral keys deleted after the handshake: stealing the server key later can't decrypt recordings.
🚫
Never skip verificationAdd the right root (RootCAs, NODE_EXTRA_CA_CERTS) instead of InsecureSkipVerify / rejectUnauthorized: false.
I want to…Use
Serve HTTPS in Gosrv.ListenAndServeTLS("fullchain.pem", "privkey.pem") with TLSConfig: &tls.Config{MinVersion: tls.VersionTLS12}
Serve HTTPS in Nodehttps.createServer({ cert, key, minVersion: "TLSv1.2" }, handler)
Get certs automaticallyACME: certbot, Caddy, cert-manager, or Go's autocert.Manager
Trust a private CA (Go)pool, _ := x509.SystemCertPool(); pool.AppendCertsFromPEM(pem) → tls.Config{RootCAs: pool} on a cloned DefaultTransport
Trust a private CA (Node)NODE_EXTRA_CA_CERTS=ca.pem, or ca: [...tls.rootCertificates, ca]
Require client certs (mTLS)Go ClientAuth: tls.RequireAndVerifyClientCert, ClientCAs: pool · Node requestCert: true, rejectUnauthorized: true, ca
See what a server sendsecho | openssl s_client -connect host:443 -servername host -showcerts
Check a cert's names and dates… | openssl x509 -noout -subject -issuer -dates -ext subjectAltName
Watch the handshakecurl -v https://host/ (add --cacert ca.pem, --resolve host:443:IP)
Alert before expiryopenssl x509 -checkend 1209600 -noout -in cert.pem (14 days) + probe live endpoints
Reload certs without restartGo tls.Config.GetCertificate · Node server.setSecureContext()
Resume sessions from a Go clienttls.Config{ClientSessionCache: tls.NewLRUClientSessionCache(256)}
Force HTTPS in browsersStrict-Transport-Security: max-age=63072000; includeSubDomains
ErrorFirst thing to check
x509: certificate signed by unknown authorityMissing intermediate? Private CA not added? Corporate proxy?
x509: certificate is valid for …, not … / ERR_TLS_CERT_ALTNAME_INVALIDName you connected to vs the SAN list
certificate has expired or is not yet validCert dates and the client's clock
tls: certificate requiredServer wants mTLS; send a client cert
Finished reading?Mark it complete to track your progress on a roadmap. Saved in this browser only.
Up next in NetworkingWebSockets, SSE & Long-polling
Read →