Skip to content
Français
Account: not signed in, sync available My learning
Select theme
Français

TLS diagnostics: debug SSL errors with openssl

By updated

Improve this page

A curl: (60) SSL certificate problem, or a certificate that "works in the browser but not on the command line"? The answer fits in one command: openssl s_client -connect host:443 -servername host. It reveals the TLS version negotiated, the certificate chain and a verify code that points at the exact cause. This guide teaches you to read that output, to recognize the most frequent errors (incomplete chain, expired certificate, wrong SNI, hostname that does not match) and to reproduce them in a lab with badssl.com.

What you will learn

  • Read the output of openssl s_client: TLS version, cipher suite, verify code.
  • Understand the certificate chain and the verify codes (0, 10, 18, 19, 20, 21).
  • Master pitfall number one: the incomplete chain that "works in Chrome but not with curl".
  • Master SNI (and its encrypted successor ECH) to obtain the right certificate.
  • Avoid the hostname false positive: openssl does not verify the host name by default.
  • Monitor expiry and test which TLS versions a server accepts.

Prerequisites

  • Basic knowledge of HTTPS and TLS (see the guide HTTP and HTTPS, in French).
  • A Linux, macOS or WSL terminal with openssl and curl installed.
  • An Internet connection to reach the badssl.com test servers.

openssl s_client: see a certificate in one command

openssl s_client opens a TLS connection to a server and shows everything that happens on it. It is the reference diagnostic tool:

Terminal window
echo | openssl s_client -connect exemple.com:443 -servername exemple.com

Two options structure the command:

  • -connect host:443: the target (host and port).
  • -servername host: sets the SNI. OpenSSL already sends it by itself when -connect carries a DNS name, but it becomes indispensable as soon as you target an IP address (see below).

The echo | closes standard input so that the command does not hang after the handshake.

Reading the output: version, cipher, verify code

Three lines sum up the state of the connection. Here is the real output for a healthy site:

Protocol : TLSv1.3
Cipher : TLS_AES_256_GCM_SHA384
Verify return code: 0 (ok)
  • Protocol: the TLS version actually negotiated (here TLS 1.3).
  • Cipher: the cipher suite selected.
  • Verify return code: the result of the chain validation. 0 (ok) means all is well; any code other than 0 signals a problem.

This return code is the heart of the diagnosis. Learn to read it and you identify the cause in seconds.

The certificate chain and the verify codes

A certificate is never alone. It belongs to a chain of trust:

Server certificate (leaf) -> Intermediate(s) -> Root CA

The server must send the leaf and the intermediates; the root is already in the system's trust store. If a link is missing or is not trusted, validation fails with a precise code:

CodeExact messageCause
0okcomplete and valid chain
10certificate has expirednotAfter date passed
18self-signed certificateself-signed certificate (leaf)
19self-signed certificate in certificate chainuntrusted root (unknown CA)
20unable to get local issuer certificateissuer not found (missing intermediate or CA)
21unable to verify the first certificateincomplete chain (missing intermediate)

These six codes cover almost every day-to-day TLS incident. Codes 20 and 21 both point at a chain problem. The 20 appears when the issuer is missing from the local store, for example an internal CA not yet installed on the client; the 21 is the final code of a chain that is incomplete on the server side, detailed below.

Pitfall number one: the incomplete chain

This is the most frequent error in production, and the most confusing: the site works in the browser but fails with curl or openssl.

The reason: a browser knows how to complete the chain by itself (cache, retrieval of the intermediate through the certificate's AIA field, Authority Information Access, the URL where the intermediate can be downloaded). curl and openssl do not: if the server does not send the intermediate, they cannot climb up to the root.

Count the certificates sent with -showcerts:

Terminal window
# Healthy server: several certificates (leaf + intermediates)
echo | openssl s_client -connect exemple.com:443 -servername exemple.com -showcerts 2>/dev/null \
| grep -c "BEGIN CERTIFICATE"
# 4
# Incomplete chain: a single certificate
echo | openssl s_client -connect incomplete-chain.badssl.com:443 \
-servername incomplete-chain.badssl.com -showcerts 2>/dev/null \
| grep -c "BEGIN CERTIFICATE"
# 1

The faulty server sends only one certificate and openssl returns:

Verify return code: 21 (unable to verify the first certificate)

Server-side fix: serve the fullchain (leaf + intermediates), not just the leaf certificate. This is exactly what the Nginx (in French) guide recommends (ssl_certificate pointing at the fullchain.pem), and what Traefik and automatic HTTPS produces automatically.

SNI: getting the right certificate

The SNI (Server Name Indication) is the host name sent in the first message of the handshake so that the server knows which certificate to present when several sites share one IP address (shared hosting, CDN, reverse proxy).

Since OpenSSL 1.1.1, s_client fills in the SNI by itself with the name passed to -connect, provided it is a DNS name: the OpenSSL 3.0 manual page says so, and the measurement below confirms it. It sends no SNI at all for an IP address, nor for a name without a dot such as localhost, and -noservername removes it deliberately. In those cases, the server answers with its default certificate, often the one of another domain, and you end up diagnosing a false "wrong certificate".

Measured with OpenSSL 3.0.13, against an openssl s_server that presents a second certificate, with CN certificat-sni-localhost (the CN is a lab name, unrelated to localhost), when the SNI is whoami.local.test, and its default certificate otherwise. Only the Peer certificate lines are reproduced:

$ openssl s_client -connect whoami.local.test:4443 -brief
Peer certificate: CN = certificat-sni-localhost
$ openssl s_client -connect whoami.local.test:4443 -noservername -brief
Peer certificate: CN = certificat-par-defaut
$ openssl s_client -connect 127.0.0.1:4443 -brief
Peer certificate: CN = certificat-par-defaut
$ openssl s_client -connect 127.0.0.1:4443 -servername whoami.local.test -brief
Peer certificate: CN = certificat-sni-localhost

The case that catches you out in production is testing a specific backend or load balancer by its IP address: without -servername, you read the machine's default certificate, not the site's. Writing -servername every time costs nothing and makes the command correct whatever the form of -connect:

Terminal window
# Explicit: the SNI asks for the right site, whatever the target of -connect
echo | openssl s_client -connect exemple.com:443 -servername exemple.com 2>/dev/null \
| openssl x509 -noout -subject

The hostname false positive

A subtle and important pitfall: openssl does not verify the host name by default. It validates the chain, not the match between the domain requested and the certificate.

Demonstration with wrong.host.badssl.com, whose certificate is only valid for *.badssl.com:

Terminal window
echo | openssl s_client -connect wrong.host.badssl.com:443 \
-servername wrong.host.badssl.com 2>/dev/null | grep "Verify return code"
# Verify return code: 0 (ok) <- the chain is valid... but the hostname does not match!

The 0 code here is misleading: the chain is good, but the certificate does not cover that name. There are two ways to detect the hostname mismatch:

Terminal window
# 1. Explicitly ask openssl to verify the name
echo | openssl s_client -connect wrong.host.badssl.com:443 \
-servername wrong.host.badssl.com -verify_hostname wrong.host.badssl.com 2>/dev/null \
| grep "Verify return code"
# 2. Use curl, which verifies the hostname by default
curl -Iv https://wrong.host.badssl.com
# curl: (60) SSL: no alternative certificate subject name matches target host name

Remember: to validate that a certificate really covers a domain, rely on curl or add -verify_hostname to openssl. The name must appear in the CN or, above all, in the SANs (Subject Alternative Names).

The command line accepts a certificate without a SAN, browsers do not

Beware of a rule that is often stated too broadly: "a certificate without a SAN is rejected by modern clients". That is true of browsers, which stopped looking at the CN long ago. It is not true of the command line.

Measured with OpenSSL 3.5.5 and curl 8.12.1 on a certificate with no SAN, whose CN matches the name requested:

Terminal window
openssl verify -CAfile ca.crt -verify_hostname serveur.exemple.test srv.crt
srv.crt: OK

curl accepts it in the same way and returns an HTTP 200. Both tools fall back on the CN when the SAN is absent, and only protest with a different name:

error 62 at 0 depth lookup: hostname mismatch
curl: (60) SSL: certificate subject name 'serveur.exemple.test' does not match
target host name 'autre.exemple.test'

The practical trap is right there: you validate your certificate with openssl, everything looks fine, and it breaks in Firefox. Always add a SAN, even when openssl does not complain, and check that it is there explicitly:

Terminal window
openssl x509 -in srv.crt -noout -ext subjectAltName

Checking expiry

An expired certificate returns code 10. To monitor the deadline, two commands:

Terminal window
# Validity dates (notBefore / notAfter)
echo | openssl s_client -connect exemple.com:443 -servername exemple.com 2>/dev/null \
| openssl x509 -noout -dates
# notBefore=May 31 21:39:12 2026 GMT
# notAfter=Aug 29 21:41:26 2026 GMT
# Does it expire within 24h? exit code 0 = still valid, 1 = expires within 24h or already expired
echo | openssl s_client -connect exemple.com:443 -servername exemple.com 2>/dev/null \
| openssl x509 -noout -checkend 86400 && echo "valid > 24h"

-checkend N returns a usable exit code: it is the ideal tool for a monitoring script that alerts before expiry. With the arrival of short-lived certificates (Let's Encrypt is going down to 6 to 45 days), that monitoring becomes indispensable. To automate issuance and rotation, see Vault PKI (in French).

Testing which TLS versions are accepted

In 2026, TLS 1.3 is the target and TLS 1.2 is still accepted. TLS 1.0 and 1.1 are deprecated (RFC 8996, 2021). A properly hardened server must reject the old versions. Check it by forcing each version:

Terminal window
# Must SUCCEED
echo | openssl s_client -connect exemple.com:443 -servername exemple.com -tls1_3 2>/dev/null | grep Protocol
# Protocol : TLSv1.3
echo | openssl s_client -connect exemple.com:443 -servername exemple.com -tls1_2 2>/dev/null | grep Protocol
# Protocol : TLSv1.2
# Must FAIL (a server or a client that no longer accepts TLS 1.1)
echo | openssl s_client -connect exemple.com:443 -tls1_1 2>&1 | grep -iE "no protocol|alert"
# ...:no protocols available

A failure on -tls1_1 is therefore the expected result, not a bug. The no protocols available message comes from the client: your own OpenSSL refuses the version, through its default security level (SECLEVEL 2 on Debian and Ubuntu) or a MinProtocol in openssl.cnf; a remote server that refuses it rather returns a tlsv1 alert protocol version alert.

Lab: reproduce each error with badssl.com

The badssl.com service exposes subdomains designed to trigger each error. Reproduce them to anchor the codes:

  1. Prepare the test command

    Terminal window
    verif() { echo | openssl s_client -connect "$1":443 -servername "$1" 2>/dev/null | grep "Verify return code"; }
  2. Trigger each error

    Terminal window
    verif expired.badssl.com # 10 (certificate has expired)
    verif self-signed.badssl.com # 18 (self-signed certificate)
    verif untrusted-root.badssl.com # 19 (self-signed certificate in certificate chain)
    verif incomplete-chain.badssl.com # 21 (unable to verify the first certificate)
  3. Observe the hostname false positive

    Terminal window
    verif wrong.host.badssl.com # 0 (ok) -> trap: valid chain, wrong hostname
badssl.com subdomainVerify codeError demonstrated
expired10expired certificate
self-signed18self-signed (leaf)
untrusted-root19untrusted root
incomplete-chain21incomplete chain
wrong.host0hostname not verified by openssl

Complete audit tools

openssl s_client is perfect for a targeted diagnosis. For a complete audit of a server (protocols, suites, vulnerabilities), dedicated tools go further:

ToolUse
testssl.shthe reference TLS audit: standalone Bash script, tests versions, suites, vulnerabilities, chain, OCSP
sslscanquickly enumerates the protocols and suites supported
nmap --script ssl-enum-ciphersgrades each suite from A to F, readable for a report
curl -v / --cert-statussee the handshake and check OCSP during a real request
Terminal window
# Complete audit of a server
testssl.sh exemple.com
# Quick enumeration of the suites, with grades
nmap --script ssl-enum-ciphers -p 443 exemple.com

testssl.sh is the tool to know for a reproducible audit: no heavy dependency, detailed output, and it detects the historical vulnerabilities (Heartbleed, ROBOT) on top of the configuration.

Hands-on

An openssl output reads all the better once you have taken it apart yourself: this lab has you inspect a real TLS certificate, its chain and its validity dates, straight from a disposable shell.

The second lab moves the question onto a published service: instead of reading a certificate, you verify the identity that an entry point presents and the chain it actually serves, from a client that only knows the authority it expects. It requires a complete machine, and it belongs to the Publish a web service training, which takes this lesson as its own.

Key points

  • The reference command is openssl s_client -connect host:443 -servername host; OpenSSL sends the SNI on its own for a DNS name, but -servername is indispensable when you target an IP address.
  • The Verify return code points at the cause: 10 expired, 18 self-signed, 19 untrusted root, 20 issuer not found, 21 incomplete chain.
  • "Works in the browser, not with curl" is almost always an incomplete chain: serve the fullchain.
  • openssl does not verify the hostname by default (a misleading 0 code): use curl or -verify_hostname.
  • Monitor expiry with openssl x509 -checkend 86400 (an exit code usable in monitoring).
  • In 2026, target TLS 1.3, accept TLS 1.2, reject TLS 1.0/1.1 (RFC 8996).
  • For a complete audit, testssl.sh is the reference tool.

Test your knowledge

Eight questions on the verify codes that come up in incidents, the incomplete chain that passes in the browser but not on the command line, and what openssl s_client does, or does not do, on your behalf: sending the SNI and verifying the host name. An objective assessed in the LFCS path.

Knowledge check

Check what you have learned with this interactive quiz

8 questions
5 min
70% to pass

How it works

  • The timer starts when you click Start
  • Multiple choice, true/false and short answer questions
  • You can move between questions
  • Detailed results are shown at the end

Starts the quiz and the timer

FAQ: frequently asked questions about TLS diagnostics

The questions come up mostly about the incomplete chain and about the gap between browser and command line. The rule of the lesson settles it almost every time: read the Verify return code first, then count the certificates sent.

Is this project useful to you?

The guides, courses, quizzes and labs stay free. If you use them regularly, you can contribute to their upkeep and development.

Become a supporting member (new tab) One-time support (new tab) Learn more

Follow the project on LinkedIn