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

Steps

  1. 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] / UNDEF

    One flight out, one flight in, one Finished back. Unknown (8) is EncryptedExtensions, type 8, which this curl build has no name for.

  2. 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] / UNDEF

    Ten 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.

  3. 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.091522

    Subtract to get the handshake alone: 44.6 ms here. The same subtraction on the TLS 1.2 run gave 74.4 ms.

  4. 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.

  5. 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-SHA384

    The 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

Sign: The record header says TLS 1.0 and the connection is reported as legacy.Cause: The first record of any handshake carries the legacy version field 0x0301 for compatibility with old middleboxes. The negotiated version lives in the ServerHello extension, which is why the trace above shows TLS 1.3 on the same connection.
Sign: A failing handshake produces no output at all.Cause: OpenSSL writes handshake errors to stderr. Piping stdout alone discards them. Add 2>&1 to every s_client command, and </dev/null so the client does not sit waiting on input.
Sign: A certificate error is filed as a TLS configuration defect.Cause: A certificate that is expired, self-signed or issued for another name still completes the handshake. Step 5 shows both facts in one output. The version and cipher settings are untouched by that class of failure.

What to check next

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.

intermediate7 minpublished updated Maks Verny