TLS handshake and certificates
How HTTPS sets up encryption: the TLS handshake, certificates, the chain of trust, and what the browser checks.
Why a DevOps engineer needs this
HTTP sends everything in plain text: anyone on the path (Wi-Fi, an ISP, a proxy) sees the requests and responses. HTTPS is the same HTTP wrapped in TLS. TLS gives you three things: encryption (the content can't be read), integrity (bytes can't be swapped without being noticed) and authentication (you're talking to the real server, not an impostor).
Certificates and TLS settings cause one of the most common production
outages: the certificate expired, the intermediate certificate was
forgotten, the domain isn't listed in the certificate. The site still
"works", but clients refuse to connect to it. You diagnose these cases
from the output of curl -v and openssl s_client, and for that you
need to understand what happens when a connection is made.
Order: TCP, then TLS, then HTTP
1. TCP: SYN → SYN-ACK → ACK (connection established)
2. TLS handshake: agree on cipher, keys, and verify the certificate
3. HTTP: GET / over the now-encrypted channel
At the TCP stage there's no encryption yet, it only appears after the TLS handshake. That's why the first request goes out later than over plain HTTP. A full TLS 1.2 handshake costs two extra round trips (there and back), TLS 1.3 needs just one.
This is the scheme for HTTP/1.1 and HTTP/2. In HTTP/3, QUIC over UDP replaces TCP, and TLS 1.3 is built right into it, but certificates are checked the same way.
Versions in use: 1.2 and 1.3
TLS 1.0 and 1.1 are banned by RFC 8996 (2021): modern browsers don't support them, and security scanners immediately flag such a server in red. There are two working versions, TLS 1.2 and TLS 1.3.
In nginx the ssl_protocols directive controls the versions:
ssl_protocols TLSv1.2 TLSv1.3;
Since nginx 1.23.4 this is also the default value. People write the line anyway so the versions are visible in the config, and they don't list the old versions.
The client offers versions, the server picks. What was actually
negotiated on a given connection shows up in curl -v's output:
* SSL connection using TLSv1.3 / AEAD-CHACHA20-POLY1305-SHA256 / [blank] / UNDEF
If the server only supports TLS 1.2, you'll see TLSv1.2 there, even
when the client supports 1.3.
What happens in the handshake
Simplified, for TLS 1.3:
- Client hello. The client sends the versions and ciphers it
supports, a random number, and its half of the key exchange
(
key_share). This also carries SNI (Server Name Indication), the domain name in plain text, so a server hosting several sites on one IP knows which certificate to serve. - Server hello. The server picks a version and cipher, sends its own random number and its half of the key exchange. From this point both sides compute a shared session key, and the rest of the handshake is already encrypted.
- Certificate and signature. The server sends its certificate (already encrypted) and signs the handshake with its private key. The client verifies the certificate and the signature. HTTP data follows after this.
The session key is symmetric and fast; it encrypts all the traffic. The certificate is for something else: proving that the key exchange happened with the actual domain owner, not with someone in the middle.
In TLS 1.2 the order is different: the certificate goes out in plain
text, and the key is computed after it. That's why curl -v against a
TLS 1.2 server shows Server key exchange and Client key exchange
lines, which TLS 1.3 doesn't have.
Certificate and chain of trust
A certificate is the server's public key, the names it was issued for, an expiration date, and a certificate authority's signature (CA, Certificate Authority). The client trusts the server because the certificate is signed by a CA the client already trusts.
Trust is built as a chain:
Root CA (in the OS/browser store)
└─ Intermediate CA (signed by the Root)
└─ Server certificate (signed by the Intermediate)
Root certificates sit on the client ahead of time: in the OS store, the
browser store, or in a file pointed to with curl's --cacert. The
server sends its own certificate and the intermediates during the
handshake. It doesn't send the root: it's useless to the client, which
decides whether to trust it based on its own store.
The client walks up the chain to a root it trusts. If a link is missing, the chain breaks and the client refuses to connect.
What actually gets checked:
- the signature chain reaches a trusted root,
- the validity period hasn't expired,
- the name you connected with is in the certificate's SAN (Subject Alternative Name) field; browsers have long stopped using the CN field for this,
- the certificate hasn't been revoked.
Revocation has changed. For years it was checked via OCSP, a request to
the CA's server during the connection. On May 7, 2025 Let's Encrypt
stopped including an OCSP address in new certificates, and on August 6,
2025 it shut down its OCSP responders. Revocation is now reported
through CRLs, lists of revoked certificates that browsers download
ahead of time. For a server owner the takeaway is simple:
ssl_stapling from old guides for Let's Encrypt certificates no longer
does anything.
Seeing it for yourself
openssl s_client shows the handshake in detail:
openssl s_client -connect example.com:443 -servername example.com
depth=3 C=US, O=SSL Corporation, CN=SSL.com TLS ECC Root CA 2022
...
depth=0 CN=example.com
...
subject=CN=example.com
issuer=C=US, O=SSL Corporation, CN=Cloudflare TLS Issuing ECC CA 3
...
New, TLSv1.3, Cipher is TLS_AES_256_GCM_SHA384
Protocol: TLSv1.3
...
Verify return code: 0 (ok)
subject is who the certificate was issued to, issuer is who signed
it. Verify return code: 0 (ok) means the chain came together and the
certificate is valid. The command waits for input; exit with Ctrl+C or
by adding </dev/null at the end.
The -servername flag sets SNI. OpenSSL 1.1.1 and newer takes the name
from -connect on its own, if that's a domain and not an IP. LibreSSL,
which ships on macOS by default, doesn't do that: without
-servername, example.com aborts the handshake with
alert handshake failure. So it's better to always write the flag.
Connecting by IP means SNI only comes from -servername, otherwise a
server with several sites serves its default certificate, which could
turn out to be someone else's.
LibreSSL's line format is a bit different, but the fields are the same.
curl -v https://example.com shows the same thing in the lines starting
with *:
* SSL connection using TLSv1.3 / AEAD-CHACHA20-POLY1305-SHA256 / [blank] / UNDEF
* Server certificate:
* subject: CN=example.com
* start date: Sep 26 22:49:11 2026 GMT
* expire date: Dec 25 22:56:35 2026 GMT
* subjectAltName: host "example.com" matched cert's "example.com"
* issuer: C=US; O=SSL Corporation; CN=Cloudflare TLS Issuing ECC CA 3
* SSL certificate verify ok.
When verification fails, curl exits with code 60 and prints the reason:
| curl's message | What's wrong |
|---|---|
certificate has expired | the validity period has expired |
no alternative certificate subject name matches target hostname | the name isn't in the SAN |
unable to get local issuer certificate | the chain doesn't reach a trusted root |
The -k (--insecure) flag disables verification. It helps you look at
a certificate that doesn't pass verification, but with it you no longer
get a "trust it or not" verdict: the SSL certificate verify result
line isn't meaningful with -k.
Common mistakes
- No intermediate certificate. The server only sends its own
certificate. Some browsers forgive this: they remember intermediate
certificates from other sites or fetch them on their own.
curl, mobile apps and other services fail withunable to get local issuer certificate. Check withcurl -voropenssl s_client, not a browser. - The domain isn't in the certificate. It was issued for
example.com, but users go towww.example.com. You need both names in the SAN. - Expired certificate. The validity period ran out, auto-renewal didn't fire, or a new file was issued but nginx never reloaded its config.
- Checking without SNI.
openssl s_clientwithout-servername(by IP or from LibreSSL) against a server with several sites shows the default certificate, and you end up looking for the bug in the wrong place. -kin scripts and health checks. Verification is off, and an expired or swapped certificate goes unnoticed.
What you'll need for the tasks
- Docker 20.10 or newer with Compose v2, bash.
curl -v: theSSL connection using,subject,issuer,expire datelines (the "Seeing it for yourself" section).- curl's verification error messages and what they mean (the table in
the same section); why
-kdoesn't work for a verdict. - What the server should send in the handshake and what it doesn't send (the "Certificate and chain of trust" section).
docker compose -f setup/docker-compose.yml exec <service> <command>: a command inside the lab environment's container;nginx -tandnginx -s reload: check the nginx config and reload it without stopping.
In the next lesson we'll cover getting and automatically renewing certificates through Let's Encrypt, so they don't expire in production.
Done reading?
Mark the lesson as read to move your track progress forward.
Lesson marked as done.
Go through it with an AI agent
The agent explains anything unclear in plain words, gives you 2 labs for this lesson and drills you on interview questions. $49 one-time, lifetime access.
Tell the agent: use MCP izi, give me lesson networking-advanced-01-tls-handshake
Progress saved in this browser
Clear your history or open this on your phone, and the marks will be gone. Leave your email and they'll move to your account. No password needed.
Done. moved to your account. You can see your progress on any device. My progress