How to check the TLS handshake
Run curl -sI https://example.com/ -o /dev/null -v and read the lines starting with TLSv. They list every handshake message in the order it crossed the wire: Client hello, Server hello, Certificate, Finished. The closing SSL connection using line reports the version and suite the exchange agreed on.
Why check this
Most TLS defects surface as one opaque client error. The handshake trace turns that into a position: the exchange stopped after Server hello, or after Certificate, or it completed and the failure is elsewhere. Testers need this when an integration partner reports a connection error your monitoring never sees, and when a service that works from a laptop fails from a container. It also gives you the connect cost in milliseconds, which is the part of page latency a certificate or protocol change moves.
Prerequisites
- curl 7.52 or later. Its
-voutput names each handshake message and its numeric type. See the curl manual. - OpenSSL 1.1.1 or later for
s_client -msg, which prints records rather than messages. - The TLS 1.3 message flow in RFC 8446 section 2 if you want the specification wording next to the trace.
Steps
- Step 1.
Trace a modern handshake and keep only the protocol lines.
curl -sI https://www.cloudflare.com/ -o /dev/null -v 2>&1 | grep -E '^\* (TLSv|SSL connection)'* TLSv1.3 (OUT), TLS handshake, Client hello (1): * TLSv1.3 (IN), TLS handshake, Server hello (2): * TLSv1.3 (IN), TLS handshake, Unknown (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_256_GCM_SHA384 / [blank] / UNDEFOne flight out, one flight in, one Finished back.
Unknown (8)is EncryptedExtensions, type 8, which this curl build has no name for. - Step 2.
Pin the same request to TLS 1.2 and count the extra messages.
curl -sI https://www.cloudflare.com/ -o /dev/null --tlsv1.2 --tls-max 1.2 -v 2>&1 | grep -E '^\* (TLSv|SSL connection)'* TLSv1.2 (OUT), TLS handshake, Client hello (1): * TLSv1.2 (IN), TLS handshake, Server hello (2): * TLSv1.2 (IN), TLS handshake, Certificate (11): * TLSv1.2 (IN), TLS handshake, Server key exchange (12): * TLSv1.2 (IN), TLS handshake, Server finished (14): * TLSv1.2 (OUT), TLS handshake, Client key exchange (16): * TLSv1.2 (OUT), TLS change cipher, Change cipher spec (1): * TLSv1.2 (OUT), TLS handshake, Finished (20): * TLSv1.2 (IN), TLS change cipher, Change cipher spec (1): * TLSv1.2 (IN), TLS handshake, Finished (20): * SSL connection using TLSv1.2 / ECDHE-ECDSA-CHACHA20-POLY1305 / [blank] / UNDEFTen messages instead of seven, and the client has to answer before the server can finish. That second round trip is the cost TLS 1.3 removed.
- Step 3.
Measure what the extra round trip is worth on this route.
curl -sI https://www.cloudflare.com/ -o /dev/null --tlsv1.3 -w 'tcp=%{time_connect} tls=%{time_appconnect}\n'tcp=0.046865 tls=0.091522Subtract to get the handshake alone: 44.6 ms here. The same subtraction on the TLS 1.2 run gave 74.4 ms.
- Step 4.
Drop to the record layer when the message names are not enough.
openssl s_client -connect www.cloudflare.com:443 -servername www.cloudflare.com -msg </dev/null 2>&1 | grep -E 'Handshake |Alert '>>> TLS 1.3, Handshake [length 013b], ClientHello <<< TLS 1.3, Handshake [length 007a], ServerHello <<< TLS 1.3, Handshake [length 000a], EncryptedExtensions <<< TLS 1.3, Handshake [length 0d7e], Certificate <<< TLS 1.3, Handshake [length 0050], CertificateVerify <<< TLS 1.3, Handshake [length 0034], Finished >>> TLS 1.3, Handshake [length 0034], Finished >>> TLS 1.3, Alert [length 0002], warning close_notify>>>is sent,<<<is received. The certificate is 3454 bytes here, which is why it lands in its own packet. - Step 5.
Read a handshake that completes and still fails, so you can tell the two failure classes apart.
openssl s_client -connect expired.badssl.com:443 -servername expired.badssl.com </dev/null 2>&1 | grep -E 'verify error|^New,'verify error:num=10:certificate has expired New, TLSv1.2, Cipher is ECDHE-RSA-AES256-GCM-SHA384The exchange finished. The rejection came from certificate validation afterwards, which is a different fix from a version or cipher mismatch.
How to read the result
| What you see | What it means | What to do |
| --- | --- | --- |
| Client hello with no Server hello | The server never answered the offer | Suspect a firewall, a closed port or a TCP reset. Check time_connect first. |
| alert protocol version after Client hello | No shared protocol version | Compare the version ranges on both sides. |
| alert handshake failure after Client hello | Version agreed, no shared cipher suite | Probe the cipher families before changing anything. |
| Certificate received, then verify error | The handshake worked, validation did not | Fix the chain or the expiry, not the TLS settings. |
| Finished in both directions | The handshake succeeded | Any remaining failure is above TLS. |
Common mistakes
What to check next
- How to check TLS version of a website: reads the final line of this trace correctly.
- How to check cipher suites of a server: the next step after a handshake failure alert.
- How to check certificate chain: what to do once Certificate arrives but validation fails.
- How to test SSL configuration: the sweep that runs after the handshake is understood.
FAQ
Why does curl print Unknown (8) in a TLS 1.3 handshake?
Message type 8 is EncryptedExtensions, added by TLS 1.3. Older name tables inside curl and OpenSSL do not carry it, so the trace prints the number instead. It is a normal part of every TLS 1.3 handshake.
How do I measure only the handshake?
Subtract time_connect from time_appconnect in the curl -w output. Both are cumulative from the start of the request, so the difference is the TLS exchange with the TCP setup removed.
Can I see the handshake in a browser?
Chrome DevTools, Network tab, click the request, then the Timing panel shows an SSL segment. It gives duration but not message names, so use curl when you need to know where the exchange stopped.
Does a resumed session skip these messages?
Yes. A session resumed with a ticket sends no Certificate and no CertificateVerify. A trace that is shorter than the one above and still succeeds is usually a resumption, not a misconfiguration.
Verified
Verified by Maks Vernycurl 8.21.0openssl 3.1.1
Each output block is what the command above it printed on that date, on the host named in the step. Figures read from a live site move between runs. Compare the shape of the answer rather than the digits, and see the methodology for how a page is re-verified.
Related on this site
intermediate7 minpublished updated Maks Verny