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.
The 30-second version
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.
POST /pay HTTP/1.1Host: shop.exampleCookie: session=8f2a91c4e7Content-Type: application/x-www-form-urlencodedcard=4242424242424242&amount=10
POST /pay HTTP/1.1Host: shop.exampleCookie: session=8f2a91c4e7Content-Type: application/x-www-form-urlencodedcard=4242424242424242&amount=10
POST /pay HTTP/1.1Host: shop.exampleCookie: session=8f2a91c4e7Content-Type: application/x-www-form-urlencodedcard=4242424242424242&amount=10
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.
Just enough crypto
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:
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:
- Agreeing on a shared key in public. This is called Key exchange. §03 shows how it can possibly work.
- 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.
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
| Job | Tool | When |
|---|---|---|
| Agree on a fresh shared secret | key exchange (X25519, a kind of Diffie-Hellman) | once, in the handshake |
Prove the server is shop.example | certificate + signature | once, in the handshake |
| Encrypt and tag every byte of data | AEAD with the shared key (AES-GCM, ChaCha20-Poly1305) | the whole connection |
Agreeing on a secret in public
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.
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:
| Alice | public wire (Eve sees) | Bob | |
|---|---|---|---|
| secret | a = 4 | b = 3 | |
| public mix | A = 5⁴ mod 23 = 625 mod 23 = 4 | A = 4 → | |
| public mix | ← B = 10 | B = 5³ mod 23 = 125 mod 23 = 10 | |
| shared secret | B^a = 10⁴ mod 23 = 18 | A^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:
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
}The handshake, step by step
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:
- Agree on algorithms (which cipher, which key-exchange method).
- Agree on a shared secret with Diffie-Hellman (§03).
- 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 messages, in one table
| Message | Direction | Encrypted? | What it does |
|---|---|---|---|
| ClientHello | → | no | random value, supported versions and ciphers, key share (public value A), server name, app protocols |
| ServerHello | ← | no | chosen cipher, server's key share (public value B). Both now compute the shared secret. |
| EncryptedExtensions | ← | yes | the remaining choices, e.g. "we'll speak HTTP/2" |
| Certificate | ← | yes | the server's certificate chain (§05) |
| CertificateVerify | ← | yes | a signature over the whole handshake so far, made with the certificate's private key |
| Finished | ← and → | yes | a 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:
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.
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 h2With 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.
Certificates and the chain of trust
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 aboveThe 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
CNfield, 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:
- 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:
- Generate a key pair, and ask the CA for a certificate for
shop.example. - The CA replies with a challenge (HTTP-01 or DNS-01).
- The client publishes the token; the CA checks it.
- The CA signs the certificate and returns it, with the intermediate.
- The client saves
fullchain.pem(leaf + intermediate) andprivkey.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.
How validation fails (and what the errors mean)
When the certificate arrives in the handshake (§04), the client runs its checks before sending a single byte of your request:
- Build the chain. Starting from the leaf, find the certificate that signed it, then the one that signed that, up to a root.
- Trust the root. The root must be in the client's Trust store.
- Verify every signature along the chain.
- Check dates. "Now" must be between each certificate's start and end date. "Now" means the client's clock.
- 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".
- ✓ 1. Build a chain up to a root
- ✓ 2. Root is in my trust store
- ✓ 3. Every signature is valid
- ✓ 4. Today is inside every validity window
- ✓ 5. Hostname is listed in the leaf's SAN
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.
Forward secrecy
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.
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 from | client's random value, encrypted with the cert key | Diffie-Hellman between throwaway a and b |
| Certificate key is used to | decrypt the session secret | sign the handshake |
| Key stolen later → past traffic | readable | still secret |
| Key stolen later → future traffic | impersonation possible | impersonation possible (until the cert is replaced) |
TLS in Go and Node
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.
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.
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 filesTwo 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.
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.
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.jspool, _ := 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 CAWith 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.
mTLS and TLS termination
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.
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.
- 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.
Debugging TLS
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.exampleHere'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 problemRead 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.pemcurl -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
| Error | Which check failed | Usual real cause |
|---|---|---|
Go x509: certificate signed by unknown authority / curl unable to get local issuer certificate or self-signed certificate | chain or trust | server 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_INVALID | hostname | connecting 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_EXPIRED | dates | renewal 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 SANs | hostname | connecting 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) | mTLS | the server wants a client certificate (§09) and none was sent |
tls: handshake failure, no shared cipher, protocol version | algorithms | one side is very old (TLS 1.0 only) or configured with an overly narrow MinVersion/cipher list |
| Works in the browser, fails in code | chain | missing 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
KeyLogWriterintls.Configto an open file.
Then in Wireshark: Preferences → Protocols → TLS → "(Pre)-Master-Secret log filename". Never leave this on in production: that file decrypts everything.
TLS in production
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.Clientonly stores them if itstls.Confighas a session cache:ClientSessionCache: tls.NewLRUClientSessionCache(256). (We checked: without it,DidResumestaysfalseon 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.pemexits 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.GetCertificateis 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; preloadand 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
X25519MLKEM768by default (go doc crypto/tls.Config.CurvePreferences: "From Go 1.24, the default includes the X25519MLKEM768 hybrid post-quantum key exchange"). Disable withGODEBUG=tlsmlkem=0if 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.
Pitfalls
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).
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.
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.
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].
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).
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.
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.
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).
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.
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.
Check yourself
Answer before you click. Each explanation points back to the section that covers it.
Fresh connection, no resumption: TCP + TLS 1.3tr := &http.Transport{
TLSClientConfig: &tls.Config{InsecureSkipVerify: true},
}
client := &http.Client{Transport: tr}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));Cheat sheet
| I want to… | Use |
|---|---|
| Serve HTTPS in Go | srv.ListenAndServeTLS("fullchain.pem", "privkey.pem") with TLSConfig: &tls.Config{MinVersion: tls.VersionTLS12} |
| Serve HTTPS in Node | https.createServer({ cert, key, minVersion: "TLSv1.2" }, handler) |
| Get certs automatically | ACME: 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 sends | echo | 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 handshake | curl -v https://host/ (add --cacert ca.pem, --resolve host:443:IP) |
| Alert before expiry | openssl x509 -checkend 1209600 -noout -in cert.pem (14 days) + probe live endpoints |
| Reload certs without restart | Go tls.Config.GetCertificate · Node server.setSecureContext() |
| Resume sessions from a Go client | tls.Config{ClientSessionCache: tls.NewLRUClientSessionCache(256)} |
| Force HTTPS in browsers | Strict-Transport-Security: max-age=63072000; includeSubDomains |
| Error | First thing to check |
|---|---|
x509: certificate signed by unknown authority | Missing intermediate? Private CA not added? Corporate proxy? |
x509: certificate is valid for …, not … / ERR_TLS_CERT_ALTNAME_INVALID | Name you connected to vs the SAN list |
certificate has expired or is not yet valid | Cert dates and the client's clock |
tls: certificate required | Server wants mTLS; send a client cert |