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
opensslandcurlinstalled. - 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:
echo | openssl s_client -connect exemple.com:443 -servername exemple.comTwo options structure the command:
-connect host:443: the target (host and port).-servername host: sets the SNI. OpenSSL already sends it by itself when-connectcarries 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.3Cipher : TLS_AES_256_GCM_SHA384Verify 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 CAThe 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:
| Code | Exact message | Cause |
|---|---|---|
| 0 | ok | complete and valid chain |
| 10 | certificate has expired | notAfter date passed |
| 18 | self-signed certificate | self-signed certificate (leaf) |
| 19 | self-signed certificate in certificate chain | untrusted root (unknown CA) |
| 20 | unable to get local issuer certificate | issuer not found (missing intermediate or CA) |
| 21 | unable to verify the first certificate | incomplete 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:
# 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 certificateecho | openssl s_client -connect incomplete-chain.badssl.com:443 \ -servername incomplete-chain.badssl.com -showcerts 2>/dev/null \ | grep -c "BEGIN CERTIFICATE"# 1The 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 -briefPeer certificate: CN = certificat-sni-localhost$ openssl s_client -connect whoami.local.test:4443 -noservername -briefPeer certificate: CN = certificat-par-defaut$ openssl s_client -connect 127.0.0.1:4443 -briefPeer certificate: CN = certificat-par-defaut$ openssl s_client -connect 127.0.0.1:4443 -servername whoami.local.test -briefPeer certificate: CN = certificat-sni-localhostThe 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:
# Explicit: the SNI asks for the right site, whatever the target of -connectecho | openssl s_client -connect exemple.com:443 -servername exemple.com 2>/dev/null \ | openssl x509 -noout -subjectThe 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:
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:
# 1. Explicitly ask openssl to verify the nameecho | 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 defaultcurl -Iv https://wrong.host.badssl.com# curl: (60) SSL: no alternative certificate subject name matches target host nameRemember: 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:
openssl verify -CAfile ca.crt -verify_hostname serveur.exemple.test srv.crtsrv.crt: OKcurl 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 mismatchcurl: (60) SSL: certificate subject name 'serveur.exemple.test' does not matchtarget 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:
openssl x509 -in srv.crt -noout -ext subjectAltNameChecking expiry
An expired certificate returns code 10. To monitor the deadline, two commands:
# 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 expiredecho | 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:
# Must SUCCEEDecho | openssl s_client -connect exemple.com:443 -servername exemple.com -tls1_3 2>/dev/null | grep Protocol# Protocol : TLSv1.3echo | 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 availableA 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:
-
Prepare the test command
Terminal window verif() { echo | openssl s_client -connect "$1":443 -servername "$1" 2>/dev/null | grep "Verify return code"; } -
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) -
Observe the hostname false positive
Terminal window verif wrong.host.badssl.com # 0 (ok) -> trap: valid chain, wrong hostname
| badssl.com subdomain | Verify code | Error demonstrated |
|---|---|---|
expired | 10 | expired certificate |
self-signed | 18 | self-signed (leaf) |
untrusted-root | 19 | untrusted root |
incomplete-chain | 21 | incomplete chain |
wrong.host | 0 | hostname 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:
| Tool | Use |
|---|---|
| testssl.sh | the reference TLS audit: standalone Bash script, tests versions, suites, vulnerabilities, chain, OCSP |
| sslscan | quickly enumerates the protocols and suites supported |
| nmap --script ssl-enum-ciphers | grades each suite from A to F, readable for a report |
curl -v / --cert-status | see the handshake and check OCSP during a real request |
# Complete audit of a servertestssl.sh exemple.com
# Quick enumeration of the suites, with gradesnmap --script ssl-enum-ciphers -p 443 exemple.comtestssl.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-servernameis indispensable when you target an IP address. - The
Verify return codepoints 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
0code): usecurlor-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
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
Review
(0/0)Score by domain
A domain groups several guides and gets several questions, so its score is more reliable than a per-topic score, which often rests on a single question.
Summary
Strengths
No topic passed at 80% on at least two questions yet.
Weak areas
No topic below the pass mark.
Recommended next
Hints before you try again?
Continue the course: Define an entry point security policy
A new full quiz with random questions
Work again only on the questions you missed
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.
openssl s_client
# Full TLS connection (version, chain, verify code)
echo | openssl s_client -connect exemple.com:443 -servername exemple.com
# Just the essentials of the certificate
echo | openssl s_client -connect exemple.com:443 -servername exemple.com 2>/dev/null \
| openssl x509 -noout -subject -issuer -dates
The echo | closes standard input so the command does not hang. The -servername option sets the SNI. Since OpenSSL 1.1.1, it is sent on its own when -connect carries a DNS name, but not for an IP address: a shared host or a CDN then returns its default certificate, often the one of another domain. Add -showcerts to see the whole chain sent.Incomplete chain
The number one cause: an incomplete chain. The server sends the leaf certificate but forgets the intermediate. A browser knows how to fetch it by itself (cache, AIA field); curl and openssl do not, and they fail.Diagnosis: count the certificates sent.echo | openssl s_client -connect site:443 -servername site -showcerts 2>/dev/null \
| grep -c "BEGIN CERTIFICATE"
# 1 -> incomplete chain (a single certificate)
The return code confirms it: 20 (unable to get local issuer certificate) or 21 (unable to verify the first certificate).Fix: configure the server to serve the fullchain (leaf + intermediates), not the leaf certificate alone.The right certificate on a shared IP
SNI (Server Name Indication) is the domain name sent in the first message of the TLS handshake, so the server can choose which certificate to present when several sites share the same IP address (shared hosting, CDN, reverse proxy).Without SNI, the server returns its default certificate, often another domain, hence false "wrong certificate" reports. Since OpenSSL 1.1.1,s_client sends it on its own when -connect carries a DNS name, but not for an IP address. -servername sets it explicitly:echo | openssl s_client -connect exemple.com:443 -servername exemple.com
The SNI travels in clear text. Its successor ECH (Encrypted Client Hello, RFC 9849, March 2026) encrypts it, but is still little deployed on the self-hosted server side.The dates and checkend
# Read the validity dates
echo | openssl s_client -connect exemple.com:443 -servername exemple.com 2>/dev/null \
| openssl x509 -noout -dates
# notBefore=... / notAfter=...
# Alert if the certificate expires within 24h (86400 s)
echo | openssl s_client -connect exemple.com:443 -servername exemple.com 2>/dev/null \
| openssl x509 -noout -checkend 86400
# exit code 0 = still valid, 1 = expired or expires within 24 h
An expired certificate gives Verify return code: 10 (certificate has expired). The -checkend option returns a usable exit code: perfect for a monitoring script that alerts before the deadline, and all the more useful with short-lived certificates (45 days, or even 6).TLS 1.3 as the target, 1.2 as the fallback
| Version | State in 2026 |
|---|---|
| TLS 1.3 | standard, fast handshake |
| TLS 1.2 | accepted, fallback for older clients |
| TLS 1.0 / 1.1 | deprecated (RFC 8996, 2021) |
| SSL (all versions) | dead, to be disabled |
# Must fail (the expected result)
echo | openssl s_client -connect exemple.com:443 -tls1_1
# Must succeed
echo | openssl s_client -connect exemple.com:443 -servername exemple.com -tls1_3
A failure on -tls1_1 is the right result, whether it comes from the server (a protocol version alert) or from your own client (no protocols available).