Networking for Developers
TLS and Certificates: How HTTPS Really Works
The handshake in plain words, certificates and chains, Let's Encrypt, mTLS, and the errors you will meet.
On this page
- Words used in this lesson
- What TLS promises
- The handshake in plain words
- What is inside a certificate
- Build your own CA (so you can see every step)
- What the client checks, and how each check fails
- Trust stores and the Node trap
- Old versions and ciphers
- Let's Encrypt and ACME: certificates without paying or clicking
- mTLS: when the client also has a certificate
- Where TLS ends: termination
- HTTPS everywhere: redirects and HSTS
- A debugging recipe for TLS problems
- Try it
- Interview questions
- Key takeaways
- What's next
In one line: HTTPS is just HTTP carried inside TLS, and TLS does three jobs: it hides the data, it detects changes to the data, and it proves who the server is using a signed certificate. Almost every TLS error you will ever see is the proof step failing: the certificate is expired, is for a different name, or was signed by someone your computer does not trust.
You type https:// many times a day. Most developers never look inside. Then one morning an API call fails with UNABLE_TO_VERIFY_LEAF_SIGNATURE, or a customer says "your site is not secure", and the only advice found online is "set rejectUnauthorized to false". That advice is wrong. This lesson shows you what is really going on, with a private certificate authority that we build on our own computer, real servers with good and bad certificates, and the real error messages each one produces.
This is lesson 5 of a nine-lesson series. Earlier lessons covered IP, TCP, DNS and HTTP. TLS sits on top of TCP and below HTTP, so those ideas are the base. The next lesson, load balancers, proxies and CDNs, is where TLS is usually terminated in real systems. For the "what should I encrypt in my own app" side, see Security Lesson 5 on secrets and encryption, and for the HSTS header see Security Lesson 2.
What I ran / checked. Everything below ran on Linux with OpenSSL 3.0.13, curl 8.5.0, Node.js v22.22.0 and tcpdump 4.99.4. I created a private certificate authority (CA), signed certificates with it, started local HTTPS servers in Node, and connected to them with openssl s_client, curl and Node (https and fetch). Output is pasted from real runs; serial numbers, dates and timings change every time you run it. The clock on my machine said 12 October 2026, so the dates you see are around that. Sources I read (in my own words): the Let's Encrypt website repository on GitHub (raw markdown for the challenge types, the integration guide, the rate limits page and the certificate profiles page), and the MDN page about writing WebSocket servers (only for the lesson on real-time). Could not reach: the MDN TLS page returned 404 from the raw GitHub path I tried, and ietf.org (the TLS 1.3 RFC) was blocked by the sandbox proxy, so TLS 1.3 details here come from what I observed with curl -v, openssl s_client and tcpdump, plus my own background knowledge, and I mark that where it matters. I could not run a real Let's Encrypt issuance: it needs a public domain name and inbound internet access, which this sandbox does not have. The ACME section is explanation only.
Words used in this lesson
- TLS (Transport Layer Security) is the protocol that encrypts and authenticates a connection. SSL is its old name. Nobody should use SSL itself any more, but people still say "SSL certificate".
- HTTPS is HTTP sent through a TLS connection.
- Encryption scrambles data so only someone with the right key can read it.
- A public key and a private key are a matched pair. What one locks, only the other opens. You share the public key and guard the private key.
- A digital signature is a value made with a private key. Anyone with the matching public key can check it, and nobody can forge it without the private key.
- A certificate is a small file that says "this public key belongs to this name", and carries a signature from someone who checked.
- A CA (certificate authority) is an organisation that signs certificates. Your operating system and browser carry a list of CAs they trust, called the trust store.
- A root certificate belongs to a CA at the top. It signs itself. An intermediate certificate is signed by a root and signs the certificates websites actually use.
- A leaf certificate is the one for your website, at the end of the chain.
- SAN (Subject Alternative Name) is the list of names (and IPs) written inside a certificate. The name you visit must be in this list.
- SNI (Server Name Indication) is the hostname the client sends at the start of the handshake, so a server with many sites can pick the right certificate.
- The handshake is the opening exchange of TLS where both sides agree on keys.
- ACME is the protocol used by Let's Encrypt and others to issue certificates automatically.
- mTLS (mutual TLS) means the client also shows a certificate, not only the server.
- HSTS is a response header that tells browsers "always use HTTPS for this site".
What TLS promises
Plain HTTP over TCP has no protection. Anyone on the path can read the bytes (a café Wi-Fi, a company proxy, an internet provider) and can change them. TLS adds three promises:
| Promise | Plain meaning | What breaks it |
|---|---|---|
| Confidentiality | Others on the path cannot read the data. | Using plain HTTP. |
| Integrity | If someone changes a single byte, the receiver notices. | Plain HTTP. |
| Authentication | You are really talking to the owner of example.com, not a copy in the middle. | Skipping certificate checks. |
The third promise is the one people forget. Encryption with the wrong party is not worth much: if a thief answers your call, a scrambled line only helps the thief. So TLS makes the server prove its identity first, and the way it proves it is the certificate. That is why the certificate checks cannot be "turned off to make it work".
TLS does not protect you from a bad server. If the site is honest about being evil.example, TLS happily encrypts your data to it. And it hides the content, not the fact that you connected. Who you connect to (the IP address, and as we will see, the host name) is still visible.
The handshake in plain words
Before any HTTP is sent, the client and server do a short exchange. This is the TLS 1.3 version, which is what modern browsers and servers use. I describe it from what I saw with curl -v, and I explain each step below the output.
$ curl -sv --cacert pki/ca.crt --resolve app.lab.test:8441:127.0.0.1 https://app.lab.test:8441/* Trying 127.0.0.1:8441...* Connected to app.lab.test (127.0.0.1) port 8441* ALPN: curl offers h2,http/1.1* TLSv1.3 (OUT), TLS handshake, Client hello (1):* CAfile: pki/ca.crt* 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 change cipher, Change cipher spec (1):* TLSv1.3 (OUT), TLS handshake, Finished (20):* SSL connection using TLSv1.3 / TLS_AES_256_GCM_SHA384 / X25519 / id-ecPublicKey* ALPN: server accepted http/1.1* Server certificate:* subject: CN=app.lab.test* start date: Oct 12 03:42:18 2026 GMT* expire date: Jan 10 03:42:18 2027 GMT* subjectAltName: host "app.lab.test" matched cert's "app.lab.test"* issuer: O=Lab Corp; CN=Lab Root CA* SSL certificate verify ok.> GET / HTTP/1.1> Host: app.lab.test:8441(I removed curl's { [5 bytes data] noise lines to keep it short.) Here is the story, in order:
- Client hello. The client says: "These are the TLS versions and cipher suites I support. Here is my half of a key exchange (a number derived from a secret I just made up). The name I want is
app.lab.test(that is SNI). And I can speak HTTP/2 or HTTP/1.1 (that is ALPN)." - Server hello. The server picks one version and one cipher suite and sends its half of the key exchange. At this moment both sides can compute the same secret key without ever sending it. This clever trick is called a Diffie-Hellman key exchange; here the curve is X25519. From now on everything is encrypted.
- Encrypted extensions, Certificate, CERT verify, Finished. Still from the server, but now encrypted. The Certificate is the file that says who the server is. CERT verify is a signature made with the certificate's private key over the handshake so far, which proves the server really holds that key (a stolen copy of the certificate file is not enough). Finished is a check value over the whole handshake, so any tampering shows up.
- Client Finished. The client checks the certificate (we will list the checks in a moment), and sends its own Finished. Now HTTP can flow.
The result is that data is encrypted with a fast symmetric cipher (AES-256-GCM here), using keys that were agreed fresh for this connection. If someone records the traffic today and steals the server's private key next year, they still cannot read today's data. That property is called forward secrecy, and it comes from the key exchange using fresh temporary numbers every time.
I did not read the TLS 1.3 RFC (the site was blocked), so the story above is my own summary from the curl output and standard TLS knowledge. The message names match what curl printed.
How many round trips does this cost?
In TLS 1.3 the client sends its key share in the very first message, so the handshake takes one round trip after TCP is open. Older TLS 1.2 needed two. Add the TCP three-way handshake from lesson 2 and a fresh HTTPS request to a far-away server pays for those round trips before the first byte of HTTP. On my own machine, curl -w measured the steps like this:
dns=0.000016 connect=0.000158 tls=0.003994 ttfb=0.005137 total=0.005179 http=1.1 ip=127.0.0.1:8441connect is when TCP was ready, and tls is when TLS was ready. The gap, about 4 ms, is the TLS work (mostly CPU for the key exchange and signature on loopback; on a real network the round trip time dominates). Over a 100 ms link that one extra round trip is 100 ms. This is why keep-alive and connection reuse matter so much, and why HTTP/3 merges the transport and TLS handshakes.
What can an observer see?
I ran tcpdump on the loopback interface while making a request, and then searched the capture file for readable text. Two runs: one forced to TLS 1.3, one forced to TLS 1.2.
TLS 1.3 run: 'Lab Root CA' found in capture: 0 times, 'hello from' found: 0 times, server name 'localhost' (SNI) found: yesTLS 1.2 run: 'Lab Root CA' found in capture: 1 time, 'hello from' found: 0 timesThree lessons from this:
- The HTTP response (
hello from good...) is never visible. Confidentiality works. - In TLS 1.3 the certificate is encrypted too (step 3 above is already encrypted). In TLS 1.2 the certificate is sent in plain text, so anyone watching can read the issuer and name.
- The SNI (the host name in the client hello) was readable in plain text in the TLS 1.3 run (I only checked this one). So a network observer can see which site you visit, though not which page. There is a newer feature called Encrypted Client Hello to hide this; I did not test it, and support is not universal.
"HTTPS hides everything" is not true. It hides the path, query string, headers, cookies and body. It does not hide the destination IP, usually not the host name, and not the size and timing of the traffic.
What is inside a certificate
Here is the certificate I made for the server, printed by openssl x509 -text (I trimmed the long key and signature):
Certificate: Data: Version: 3 (0x2) Serial Number: 3a:31:73:ed:85:a7:66:1c:d7:c2:ce:22:d0:50:68:70:ee:47:e7:98 Signature Algorithm: sha256WithRSAEncryption Issuer: O = Lab Corp, CN = Lab Root CA Validity Not Before: Oct 12 03:42:18 2026 GMT Not After : Jan 10 03:42:18 2027 GMT Subject: CN = app.lab.test Subject Public Key Info: Public Key Algorithm: id-ecPublicKey Public-Key: (256 bit) ASN1 OID: prime256v1 X509v3 extensions: X509v3 Basic Constraints: CA:FALSE X509v3 Key Usage: Digital Signature X509v3 Extended Key Usage: TLS Web Server Authentication X509v3 Subject Alternative Name: DNS:app.lab.test, DNS:localhost, IP Address:127.0.0.1 X509v3 Authority Key Identifier: AF:39:E9:3B:... Signature Value: 7c:7c:b2:7e:...Read it like a form:
- Subject is who the certificate is for. Issuer is who signed it. For a root, they are the same.
- Validity is a start and an end date. The client checks the clock. A wrong clock on a laptop or a container is a classic cause of "not yet valid" and "expired" errors.
- Public key is the server's public key. The private key never appears in the certificate.
- Basic Constraints: CA:FALSE says "this is a normal site certificate, it may not sign other certificates". An intermediate has
CA:TRUE. - Extended Key Usage: TLS Web Server Authentication says what the key may be used for. A client certificate says
clientAuthinstead. - Subject Alternative Name is the list of names that matter. Modern clients check only this list, not the old
CN=field. If the name you visit is not in the SAN, the certificate is rejected, even if the CN matches. - Signature is the issuer's signature over all the fields above. Change one byte, and it no longer verifies.
Notice something that surprises people: the signature algorithm (RSA with SHA-256) belongs to the issuer's key, and the public key inside (an elliptic curve key) is the server's own key. They are different keys and can be different types. In my lab the CA key is RSA 2048 and the server key is an EC P-256 key; curl shows both: Certificate level 0: Public key type EC/prime256v1 ... Certificate level 1: Public key type RSA (2048/112 Bits/secBits).
Build your own CA (so you can see every step)
A real CA does the same thing as the commands below, but with careful identity checks and strong protection for its key. Run these in an empty folder. They need only OpenSSL.
# 1. The CA: a key, and a self-signed certificate that is allowed to sign othersopenssl genrsa -out ca.key 2048openssl req -x509 -new -key ca.key -sha256 -days 3650 -subj "/O=Lab Corp/CN=Lab Root CA" \ -addext "basicConstraints=critical,CA:TRUE" -addext "keyUsage=critical,keyCertSign,cRLSign" -out ca.crt# 2. The server: its own key, and a "certificate signing request" (CSR)openssl ecparam -name prime256v1 -genkey -noout -out server.keyopenssl req -new -key server.key -subj "/CN=app.lab.test" -out server.csr# 3. Extensions file: the names this certificate is valid forcat > server.ext <<EOFbasicConstraints=CA:FALSEkeyUsage=digitalSignatureextendedKeyUsage=serverAuthsubjectAltName=DNS:app.lab.test,DNS:localhost,IP:127.0.0.1EOF# 4. The CA signs the request, producing the certificate (valid 90 days)openssl x509 -req -in server.csr -CA ca.crt -CAkey ca.key -CAcreateserial -days 90 -sha256 \ -extfile server.ext -out server.crtNotice the order. The server makes its own key and never sends the private key to anyone. It sends only the CSR: "here is my public key and the name I want". The CA signs that. This is exactly what happens when you ask Let's Encrypt for a certificate. A site's private key never leaves the site.
Then a tiny HTTPS server in Node that uses the key and certificate:
// tlsserver.mjsimport https from "node:https"; import fs from "node:fs";const [,, name, port] = process.argv;const chain = name === "good" ? "server" : name;https.createServer( { key: fs.readFileSync(`pki/${chain}.key`), cert: fs.readFileSync(`pki/${chain}.crt`) }, (req, res) => res.end(`hello from ${name} over ${req.socket.getProtocol()}\n`)).listen(+port, "127.0.0.1");I started four of these on different ports: good (certificate signed by my CA, correct names), other (signed by my CA but only for other.lab.test), rogue (self-signed, no CA), and expired (signed by my CA, but valid only from 1 to 31 January 2025, made with openssl ca -startdate ... -enddate ... so I could create a certificate that is already out of date).
What the client checks, and how each check fails
When the client receives the server certificate, it does these checks. If any fails, it must stop.
- Is the signature valid, and does the chain reach a CA I trust? (trust)
- Is today inside the validity dates? (time)
- Does the name I asked for appear in the SAN list? (name)
- Is the certificate allowed for this use? (key usage,
CA:FALSE,serverAuth) - Is it revoked? (some clients check; support varies a lot, so do not treat revocation as a safety net.)
Now the same client code against each server, with real results. The client trusts my CA by passing its certificate file:
// client.mjsimport https from "node:https"; import fs from "node:fs";const [,, port, host, ca] = process.argv;const opts = { host: "127.0.0.1", port: +port, servername: host, headers: { host }, path: "/" };if (ca === "ca") opts.ca = fs.readFileSync("pki/ca.crt");https.get(opts, (r) => { r.setEncoding("utf8"); r.on("data", (d) => console.log("OK: " + d)); }) .on("error", (e) => console.log("FAIL:", e.code, "-", e.message));--- good server, client trusts my CAOK: hello from good over TLSv1.3--- good server, but client uses only the system trust store (my CA unknown)FAIL: UNABLE_TO_VERIFY_LEAF_SIGNATURE - unable to verify the first certificate--- certificate is for other.lab.test, we asked for app.lab.testFAIL: ERR_TLS_CERT_ALTNAME_INVALID - Hostname/IP does not match certificate's altnames: Host: app.lab.test. is not in the cert's altnames: DNS:other.lab.test--- self-signed certificateFAIL: DEPTH_ZERO_SELF_SIGNED_CERT - self-signed certificate--- expired certificateFAIL: CERT_HAS_EXPIRED - certificate has expired--- good certificate, but we ask for the name evil.lab.testFAIL: ERR_TLS_CERT_ALTNAME_INVALID - Hostname/IP does not match certificate's altnames: Host: evil.lab.test. is not in the cert's altnames: DNS:app.lab.test, DNS:localhost, IP Address:127.0.0.1Look at how helpful the messages are, if you read them. The wrong-name error even prints the list of names that are in the certificate. The same story in curl (exit code 60 for every one of these):
curl: (60) SSL certificate problem: unable to get local issuer certificatecurl: (60) SSL: no alternative certificate subject name matches target host name '127.0.0.1'curl: (60) SSL certificate problem: certificate has expiredcurl: (60) SSL certificate problem: self-signed certificateAnd in openssl s_client, which is the best tool for looking at a server from the outside:
$ echo | openssl s_client -connect 127.0.0.1:8441 -servername app.lab.test -CAfile pki/ca.crt -verify_hostname app.lab.test...Verification: OKVerified peername: app.lab.testNew, TLSv1.3, Cipher is TLS_AES_256_GCM_SHA384$ echo | openssl s_client -connect 127.0.0.1:8444 -CAfile pki/ca.crt # the expired oneverify error:num=10:certificate has expiredVerification error: certificate has expiredVerify return code: 10 (certificate has expired)Here is a cheat sheet of error names across tools:
| What is wrong | Node | curl | OpenSSL says |
|---|---|---|---|
| CA not trusted / chain incomplete | UNABLE_TO_VERIFY_LEAF_SIGNATURE, UNABLE_TO_GET_ISSUER_CERT_LOCALLY | unable to get local issuer certificate | num=20 / num=21 |
| Self-signed | DEPTH_ZERO_SELF_SIGNED_CERT, SELF_SIGNED_CERT_IN_CHAIN | self-signed certificate | num=18 / num=19 |
| Expired | CERT_HAS_EXPIRED | certificate has expired | num=10 |
| Not valid yet | CERT_NOT_YET_VALID | certificate is not yet valid | num=9 |
| Wrong name | ERR_TLS_CERT_ALTNAME_INVALID | no alternative certificate subject name matches | Hostname mismatch |
I ran the first four rows myself in this lab. The "not yet valid" row is from my memory of the error names and I did not trigger it here.
The missing intermediate: works in the browser, fails in your code
Real websites rarely sign their certificate directly with a root. The root key is kept offline and rarely used. Instead the root signs an intermediate, and the intermediate signs your leaf. Your server must send the leaf and the intermediate(s). I built this exact case:
Root: Lab Root CA -> Intermediate: Lab Issuing CA -> Leaf: app.lab.testI started two servers with the same leaf key. Server A sent only the leaf. Server B sent a "full chain" file (leaf followed by intermediate). The client trusts only the root:
port 8451 (leaf only): FAIL UNABLE_TO_VERIFY_LEAF_SIGNATUREport 8452 (leaf+intermediate): OKThe client knows the root, but it cannot connect the leaf to the root unless it sees the intermediate in the middle. The server must send it. This is probably the most common real-world certificate bug, and it hides because browsers often work anyway: some browsers can fetch a missing intermediate on their own or have it cached from another site. curl and Node do not do that, so your backend call fails while the browser shows a green padlock. When a certificate "works in Chrome but fails in my code", suspect the chain first. With Let's Encrypt tools, the file named fullchain.pem is the one to give your server, and cert.pem alone is the trap.
Test your server from the outside with openssl s_client -connect host:443 -servername host. It prints every certificate the server sends. If you see only one under "Certificate chain", and it is not signed directly by a root, your chain is incomplete.
Trust stores and the Node trap
A client trusts whatever is in its trust store. Node ships its own list of CAs. curl uses the operating system's list (CApath: /etc/ssl/certs in the output above). A private CA, like mine, is in nobody's list. For internal tools you add your own root:
- Node: set
NODE_EXTRA_CA_CERTS=/path/ca.crt(adds to the built-in list), or passcain the options for one connection. - curl:
--cacert ca.crt.
I tested Node's fetch against the good server, three ways:
-- fetch, CA unknownFAIL: fetch failed | cause: UNABLE_TO_VERIFY_LEAF_SIGNATURE-- NODE_EXTRA_CA_CERTS=pki/ca.crtOK 200 hello from good over TLSv1.3-- NODE_TLS_REJECT_UNAUTHORIZED=0(node:26373) Warning: Setting the NODE_TLS_REJECT_UNAUTHORIZED environment variable to '0' makes TLS connections and HTTPS requests insecure by disabling certificate verification.OK 200 hello from good over TLSv1.3The third way "works", and that is exactly the danger. Setting NODE_TLS_REJECT_UNAUTHORIZED=0 (or rejectUnauthorized: false, or curl -k) switches off all the checks, including the name check. After that, an attacker in the middle can present any certificate and your app will accept it. You still have encryption, but you are encrypting to whoever answered.
To prove that, I started the rogue server, a self-signed certificate that claims to be app.lab.test:
$ curl -sS --cacert pki/ca.crt https://app.lab.test:8443/ # verification oncurl: (60) SSL certificate problem: self-signed certificate$ curl -sSk https://app.lab.test:8443/ # -k: skip verificationhello from rogue over TLSv1.3With -k, the imposter was accepted without a word.
Never ship rejectUnauthorized: false or NODE_TLS_REJECT_UNAUTHORIZED=0, even "just for staging", because it tends to stay. If you need to talk to a server with a private CA, trust that CA (NODE_EXTRA_CA_CERTS or ca). That fixes the real cause and keeps the other checks.
A side lesson from my own lab: my sandbox sets HTTPS_PROXY, so my first curl calls went through a proxy. curl -v showed CONNECT app.lab.test:8441 HTTP/1.1 and the connection reset. That is a forward proxy at work (lesson 6). If a TLS call behaves strangely on your laptop at work, check env | grep -i proxy before you blame the certificate. I unset those variables for the rest of the experiments.
Old versions and ciphers
A server can refuse old protocol versions. I started a server with minVersion: "TLSv1.3" and tried to connect with TLS 1.2 only:
$ curl -sS --tls-max 1.2 --cacert pki/ca.crt https://localhost:8471/curl: (35) OpenSSL/3.0.13: error:0A00042E:SSL routines::tlsv1 alert protocol versionThe server sent a protocol version alert and closed. That is the error you see when a very old client (an old Java, an old Android, a legacy device) meets a server that has dropped TLS 1.0 and 1.1 (or 1.2). The fix is on the client side: update it. Do not weaken the server for one old device unless you truly have to.
For servers, the sensible rule is: allow TLS 1.2 and 1.3, disallow everything older, and use a maintained tool such as Mozilla's SSL configuration generator for the cipher list. I did not read that site in this session, so check it yourself for current advice.
Let's Encrypt and ACME: certificates without paying or clicking
Before 2015, getting a certificate meant paying a company and emailing files. Today most sites use a free automatic CA such as Let's Encrypt. Your server runs an ACME client (certbot, acme.sh, Caddy, or your platform does it). Here is what the ACME client does, in order:
- Creates a key pair and asks the CA for a certificate for a list of names.
- The CA says: "prove you control those names". It gives a challenge.
- The client answers the challenge. The CA checks it from the internet.
- The client sends a CSR (just like our
server.csr). The CA signs it and returns the certificate and chain. - The client installs the files and reloads the server. It repeats this again before the certificate expires.
The documentation I read lists three challenge types. In my own words:
| Challenge | How it proves control | Needs | Wildcard (*.example.com) |
|---|---|---|---|
| HTTP-01 | The CA fetches a token file from http://yourdomain/.well-known/acme-challenge/... | Port 80 reachable from the internet. If you have many servers, the file must be on all of them. | No |
| DNS-01 | The client creates a TXT record at _acme-challenge.yourdomain | A DNS provider with an API. Works for servers that are not public. Storing DNS API credentials on a web server is a risk. | Yes |
| TLS-ALPN-01 | A special certificate is shown on port 443 | Port 443, special client support | No |
Things to know that I confirmed from the Let's Encrypt pages:
- The default certificate is valid for 90 days. The guide recommends renewing when one third of the life is left, which for 90-day certificates means 30 days before expiry, and checking renewal information at least twice a day. Short lives are on purpose: they limit the damage of a stolen key and force automation.
- The newer "profiles" page describes a
tlsserverprofile with 45-day certificates (and no Common Name, since the SAN list is what matters), and ashortlivedprofile of about six days. The defaultclassicprofile stays at 90 days. Check the page for what is current when you read this; these numbers change over the years. - There are rate limits. One of them is up to 50 new certificates per registered domain, refilling about one every 202 minutes. The guide warns about one specific trap: if every container you start asks for a new certificate and you do not store the key and certificate somewhere that survives restarts, you can hit the limits and then your deploys fail. Store the issued certificate, issue once, share it.
A forgotten renewal is one of the most common causes of outages. The certificate expires at midnight and everything fails at once, for everyone, with the CERT_HAS_EXPIRED error you saw above. Automate renewal, test that the renewal job really runs, and monitor the expiry date as a separate check. The one-line check you can add to a cron job: openssl x509 -in cert.pem -noout -checkend 2592000 exits with 1 if the certificate expires within 30 days. I tested the command:
$ openssl x509 -in pki/server.crt -noout -checkend 86400 # 1 dayCertificate will not expire (exit 0)$ openssl x509 -in pki/server.crt -noout -checkend 8640000 # 100 days; my cert lives 90Certificate will expire (exit 1)$ openssl x509 -in pki/expired.crt -noout -checkend 0Certificate will expire (exit 1) # already expired, so "will expire" at time 0mTLS: when the client also has a certificate
Normal TLS proves only the server. In mutual TLS the server also asks for a client certificate and checks it against a CA. It is used between internal services (a "zero-trust" setup, service meshes) and for some partner APIs, because it proves which service is calling without a password or token.
I made a client certificate for a service called billing-service (extended key usage clientAuth) and a server that requires one:
https.createServer({ key, cert, ca: caCert, requestCert: true, rejectUnauthorized: true}, (req, res) => res.end(`hello ${req.socket.getPeerCertificate().subject.CN}\n`));no client cert -> FAIL ERR_SSL_TLSV13_ALERT_CERTIFICATE_REQUIREDwith client cert -> hello billing-servicewith rogue cert -> FAIL ECONNRESETAnd with curl:
$ curl -sS --cacert pki/ca.crt https://localhost:8462/curl: (56) OpenSSL SSL_read: ... tlsv13 alert certificate required$ curl -sS --cacert pki/ca.crt --cert pki/client.crt --key pki/client.key https://localhost:8462/hello billing-serviceA small detail worth remembering: in TLS 1.3 the client certificate is checked after the client thinks the handshake is finished. That is why the failure showed up on the first read (SSL_read) instead of at connect time. If you see an odd error just after connecting, think "the server did not like my certificate".
mTLS moves a lot of work to you: you must issue, rotate and revoke client certificates, and a leaked client key is as bad as a leaked password. Many teams use short-lived certificates and an automated issuer for this.
Where TLS ends: termination
In real systems TLS is rarely handled inside your Node app. A load balancer, a CDN or a reverse proxy usually holds the certificate and does the encryption work, then sends plain HTTP (or a second TLS connection) to your servers. This is called TLS termination.
- Terminate at the edge, plain inside. Simple and fast. Safe only if the internal network is trusted. Your app sees
http://and must trust theX-Forwarded-Protoheader to know the user used HTTPS. - Re-encrypt to the backend. The proxy opens a new TLS connection to your app. Protects data inside the network, at the cost of more certificates to manage.
- Pass-through. The proxy does not decrypt; it just forwards the TCP bytes (a layer 4 balancer). Your app terminates TLS. The proxy cannot read or route by path, but it can still route by the SNI name.
We build these in the next lesson.
HTTPS everywhere: redirects and HSTS
Once you have a certificate, serve HTTPS and redirect plain HTTP to it. But the very first request, typed as http://example.com, is plain, and an attacker in the middle can answer it with a fake page that never upgrades. The Strict-Transport-Security header closes this gap:
Strict-Transport-Security: max-age=31536000; includeSubDomainsIt tells the browser: "for the next 31,536,000 seconds (one year), never connect to this host over plain HTTP, and refuse to continue if the certificate is bad (no click-through button)." The Let's Encrypt integration guide suggests that hosting providers start customers with a default max-age of sixty days and warn them clearly, because if you later move to a host without HTTPS, browsers that cached the rule will refuse to connect. Start with a small value, check everything works, then raise it. See Security Lesson 2 for the other security headers.
A debugging recipe for TLS problems
When HTTPS fails, do these in order. Each takes seconds.
- Read the exact error.
expired,hostname mismatch,unable to get local issuer,protocol versionandself-signedpoint to completely different causes (see the table above). - Look from outside.
echo | openssl s_client -connect host:443 -servername hostshows the chain, the protocol and "Verify return code". - Look at the dates.
openssl x509 -noout -dates -subject -issuer -ext subjectAltNameon the certificate. Compare withdate -uon the client (a wrong clock breaks everything). - Check the name. Which name does the client use? Often the code connects to an IP or an internal name that is not in the SAN.
- Check the chain. Does the server send the intermediate?
- Check SNI. Many servers hold several certificates and pick by SNI. If the client sends no SNI (
openssl s_clientwithout-servername, some old clients) you get the default certificate, which is for another site. In my run,openssl s_clientwithout-servernamestill got my certificate because that server only has one. - Check the middleman. A corporate proxy that inspects TLS re-signs certificates with the company CA. Your laptop trusts that CA, your container does not.
env | grep -i proxyand look at the issuer in the certificate you actually receive.
Try it
Do these in an empty folder with OpenSSL and Node 22.
- Create a CA and a server certificate for
shop.lab.testas shown above. In thesubjectAltName, include onlyDNS:shop.lab.test. Start the Node HTTPS server and connect withcurl --cacert ca.crt --resolve shop.lab.test:8443:127.0.0.1 https://shop.lab.test:8443/. Does it work? - Now connect to
https://127.0.0.1:8443/instead, using the same--cacert. What error do you get, and why? - Make a second certificate that ends yesterday. Hint:
openssl cawith-startdateand-enddate, orfaketimeif you have it. What error do you get incurland in Node? - Make an intermediate CA (
basicConstraints=CA:TRUE,pathlen:0), sign a leaf with it, and serve only the leaf. What error does Node give? How do you fix it without changing the client? - Run the commands
openssl x509 -in server.crt -noout -checkend 86400and-checkend 8640000. What do the exit codes tell you? - Use
tcpdump -i lo -w cap.pcap 'tcp port 8443'while you make one request, thenstrings cap.pcap | grep shop. Do you see the host name? Do you see the response body?
Answers
- Yes. The name
shop.lab.testis in the SAN list and the CA is trusted. curl: (60) SSL: no alternative certificate subject name matches target host name '127.0.0.1'. The certificate has no IP address in its SAN. AddIP:127.0.0.1to fix it.certificate has expiredin curl andCERT_HAS_EXPIREDin Node.UNABLE_TO_VERIFY_LEAF_SIGNATURE. Fix it on the server by sending leaf plus intermediate (cat leaf.crt int.crt > fullchain.crt).- Exit 0 and the text "will not expire" means still valid for that many seconds. Exit 1 means it will expire within that time. Use it in monitoring.
- You should find the host name (that is SNI) and no response body. If you force TLS 1.2 you will also find the certificate subject.
Interview questions
What does TLS give you, and what does it not? (Must know)
TLS gives three things: confidentiality (others cannot read the data), integrity (changes are detected) and authentication (I am talking to the real owner of the name, proved by a certificate). It does not make the server trustworthy, it does not hide the destination IP or usually the host name, and it does nothing about application bugs. A SQL injection works fine over HTTPS.
Walk me through the TLS 1.3 handshake. (Must know)
The client sends a hello with its supported versions and ciphers, its key share, the server name (SNI) and the protocols it can speak (ALPN). The server answers with its choice and its own key share. Now both sides compute the same secret and everything after is encrypted. The server sends its certificate, a signature that proves it holds the private key, and a Finished message. The client verifies the certificate chain, the dates and the name, and sends its Finished. That costs one round trip on top of TCP. I saw exactly these messages with curl -v.
How does a client decide to trust a server's certificate? (Must know)
It checks that the certificate is signed by an intermediate, that the chain ends in a root in its trust store, that every signature is valid, that today is inside the validity dates, that the host name it asked for is in the Subject Alternative Name list, and that the key usage allows server use. If any check fails the connection must stop. The name check is what stops someone from presenting a valid certificate for a different site.
A call works in the browser but fails from our Node service with UNABLETOVERIFYLEAFSIGNATURE. What is your first guess? (Trap)
The server is not sending the intermediate certificate. Browsers can sometimes fetch or remember missing intermediates, so they hide the problem, but Node and curl do not. I reproduced this: a server sending only the leaf failed, and the same server sending leaf plus intermediate worked. I would check with openssl s_client -connect host:443 -servername host and fix the server to send the full chain. A second possible cause is a private or corporate CA that is not in Node's trust store, fixed with NODE_EXTRA_CA_CERTS.
Someone suggests NODETLSREJECTUNAUTHORIZED=0 to "fix" a certificate error. What do you say? (Trap)
No. It turns off every check, including the name check, so any attacker who can intercept the connection can present any certificate and my app will accept it. I tested it: a self-signed imposter was accepted by curl -k and rejected normally. I find the real cause (expired, wrong name, missing chain, unknown CA) and fix that. For a private CA, I add the CA to the trust store for that process.
What is SNI and why does it exist? (Must know)
Server Name Indication is the host name that the client sends in its first TLS message. The server needs it to choose the right certificate when many sites share one IP address and port, because TLS starts before any HTTP Host header can be read. It is sent in plain text, so an observer can see which site I visit, though not the page. In my packet capture I could find the name in the bytes.
What is the difference between a root and an intermediate certificate, and why do CAs use intermediates? (Senior signal)
A root is self-signed and is in the trust store. An intermediate is signed by the root and signs site certificates. CAs keep the root key offline and very safe, and use intermediates for daily signing. If an intermediate key is compromised, only that intermediate has to be revoked, not the root that millions of devices trust. The server must send the intermediates, because clients usually have only the roots.
How does Let's Encrypt know I own the domain? Compare HTTP-01 and DNS-01. (Senior signal)
With HTTP-01 the CA fetches a token from a well-known path on my domain over port 80, so it proves I control the web server. It is simple, but needs port 80 open, the file on every server, and it cannot issue wildcards. With DNS-01 I publish a TXT record at _acme-challenge.<domain>, so it proves control of the DNS. It supports wildcards and works for servers that are not public, but needs an API for my DNS provider and credentials stored where the job runs, which is a risk I limit with a narrow-scope token.
Why are certificates getting shorter lived, and what does it mean for your team? (Senior signal)
Short lives limit the time a stolen key or wrong certificate is useful, and they make revocation (which is weak in practice) less important. Let's Encrypt's default is 90 days and it has profiles of 45 days and about 6 days. The consequence is that renewal must be fully automated and monitored. I would add an expiry check with openssl x509 -checkend or my monitoring system, alert at least 14 days ahead, and store issued certificates durably so that new containers do not each request one and hit rate limits.
What is mTLS and when would you use it? (Senior signal)
In mutual TLS the client shows a certificate as well as the server, and the server checks it against a CA. It identifies a service without a shared secret. I would use it between internal services, or for a partner API with a small number of known clients. The cost is certificate issuing, rotation and revocation for each client. In my test a server that required a client certificate rejected both a client with no certificate and one with a certificate from a different CA.
Does HTTPS prevent someone knowing which sites I visit? (Trap)
No. The destination IP is visible, and the host name is visible in the SNI in the first message, in plain text, unless Encrypted Client Hello is used and supported. What is hidden is the URL path, query, headers, cookies and body. In TLS 1.3 even the server certificate is encrypted, but in TLS 1.2 it was sent in plain text, which I confirmed by searching a packet capture.
What is HSTS and what is the risk of setting it carelessly? (Senior signal)
Strict-Transport-Security tells the browser to use only HTTPS for my host for max-age seconds, and to refuse bad certificates without a bypass. It protects the first plain HTTP request from being hijacked on later visits. The risk is that the rule is cached in each browser: if my certificate breaks, or I move to a host without HTTPS, users are locked out until the max-age passes. So I start with a short value, confirm that everything including subdomains works over HTTPS, then raise it.
Key takeaways
- TLS gives confidentiality, integrity and authentication. The authentication part is the certificate check, and it must never be switched off.
- The TLS 1.3 handshake takes one round trip after TCP. The key is agreed fresh each time, which gives forward secrecy.
- A certificate links a name to a public key and carries a signature from a CA. The client checks the chain to a trusted root, the dates, and the name in the SAN list.
- The server must send its intermediate certificate. A missing intermediate works in browsers and fails in Node and curl.
- Common errors:
CERT_HAS_EXPIRED,ERR_TLS_CERT_ALTNAME_INVALID,UNABLE_TO_VERIFY_LEAF_SIGNATURE,DEPTH_ZERO_SELF_SIGNED_CERT. Each points to a different fix. rejectUnauthorized: false,-kandNODE_TLS_REJECT_UNAUTHORIZED=0accept imposters. Trust the right CA instead.- SNI (the host name) is visible to observers. In TLS 1.3 the certificate itself is encrypted.
- Let's Encrypt issues certificates through ACME using HTTP-01 or DNS-01 (wildcards need DNS-01). Certificates are short lived, so automate renewal and monitor expiry.
- mTLS makes the client prove itself too, at the cost of managing client certificates.
What's next
Next: Load Balancers, Proxies, CDNs and Firewalls. We will build the thing that usually holds your certificate: a reverse proxy. You will run real nginx and HAProxy in front of three backends, watch round-robin and health checks, see why the backend sees the proxy's IP and not the user's, and cache responses at an edge. Practise now: make the intermediate-CA experiment (Try it, number 4) work, because the fix is the same one you will use on a proxy.
Published by Jainik · Last updated · How these lessons are made
Keep learning
Related lessons
- What Happens When You Type a URL (The Full Version) · The Browser Contract
- Volumes, Networks and Running Containers Safely · Docker & Kubernetes
- Services, Ingress and Networking in Kubernetes · Docker & Kubernetes
Part of the study guide
Related series