Short answer: “SSL handshake failed” means client and server gave up before any web traffic was exchanged, because they could not agree on how to encrypt or the client rejected the server’s certificate. The usual causes, in order: an expired certificate (or a wrong clock on the client), a missing intermediate certificate, no certificate for the requested hostname, no common TLS version or cipher, or a port that does not speak TLS at all. One command tells you which:
openssl s_client -connect example.com:443 -servername example.com -verify_hostname example.com -brief </dev/nullReplace all three example.com with your hostname. On Windows, run it in WSL or Git Bash, which include OpenSSL. On macOS, /usr/bin/openssl is LibreSSL, which knows neither -brief nor -verify_hostname: install OpenSSL with brew install openssl@3 and call it as $(brew --prefix openssl@3)/bin/openssl. CONNECTION ESTABLISHED and Verification: OK mean the handshake and the certificate check work from where you run it; anything else is an error line that points at one of the causes below. Behind Cloudflare the same failure shows up as error 525 or 526.
The same failure under different names
| Client | Message | Usually means |
|---|---|---|
| Chrome, Edge | NET::ERR_CERT_DATE_INVALID | Expired certificate, or the computer’s clock is wrong |
| Chrome, Edge | ERR_SSL_VERSION_OR_CIPHER_MISMATCH | No common TLS version or cipher, or no certificate for this name |
| Chrome, Edge | ERR_SSL_PROTOCOL_ERROR | The port does not speak TLS, or the handshake broke off |
| Firefox | SSL_ERROR_HANDSHAKE_FAILURE_ALERT | The server refused: no common cipher, or no SNI |
| Firefox, Chrome | SSL_ERROR_UNRECOGNIZED_NAME_ALERT, ERR_SSL_UNRECOGNIZED_NAME_ALERT | The server hosts no site under this name, for example nginx with ssl_reject_handshake, cause 3 |
| Firefox | SSL_ERROR_RX_RECORD_TOO_LONG | Plain HTTP on an HTTPS port, cause 5 |
| curl | (35) … wrong version number | Plain HTTP on an HTTPS port |
| curl, Python, Java | unable to get local issuer certificate, PKIX path building failed | Missing intermediate, or a private CA the client does not know |
| Cloudflare | Error 525 / 526 | The handshake or the certificate between Cloudflare and your server failed, see below |
The page title depends on the kind of failure: a broken handshake shows as Secure Connection Failed in Firefox and This site can’t provide a secure connection in Chrome, a rejected certificate as Warning: Potential Security Risk Ahead and Your connection is not private. The code underneath is what matters. All of them describe the TLS handshake: the client sends a ClientHello with the hostname (SNI), the versions and the ciphers it supports; the server picks a version and cipher and sends its certificate chain; the client checks the chain, the dates and the name. A failure at any of these steps aborts the connection.
Read the handshake with openssl s_client
openssl s_client runs the handshake and prints what happened. This is real output from example.com:
CONNECTION ESTABLISHED
Protocol version: TLSv1.3
Ciphersuite: TLS_AES_256_GCM_SHA384
Peer certificate: CN = example.com
Hash used: SHA256
Signature type: ECDSA
Verification: OK
Verified peername: example.com
Server Temp Key: X25519, 253 bits
DONEUseful variants, each a separate test:
# The full chain as the server sends it (count the "BEGIN CERTIFICATE" blocks)
openssl s_client -connect example.com:443 -servername example.com -showcerts </dev/null
# Force one protocol version
openssl s_client -connect example.com:443 -servername example.com -tls1_2 -brief </dev/null
openssl s_client -connect example.com:443 -servername example.com -tls1_3 -brief </dev/null
# Also check the hostname, and abort on any verification error
openssl s_client -connect example.com:443 -servername example.com \
-verify_hostname example.com -verify_return_error -brief </dev/null
# Dates, names and issuer of the certificate the server sends
openssl s_client -connect example.com:443 -servername example.com </dev/null 2>/dev/null \
| openssl x509 -noout -subject -issuer -dates -ext subjectAltNameThe last command prints, for the real example.com:
subject=CN = example.com
issuer=C = US, O = SSL Corporation, CN = Cloudflare TLS Issuing ECC CA 3
notBefore=Sep 26 22:49:11 2026 GMT
notAfter=Dec 25 22:56:35 2026 GMT
X509v3 Subject Alternative Name:
DNS:example.com, DNS:*.example.comWhat the failures look like
These are real results from OpenSSL 3.0 against the public test hosts of badssl.com and example.com, and for the last row against a local test server limited to TLS 1.2, shortened to the decisive line:
| Test | Decisive line | Cause |
|---|---|---|
| expired.badssl.com | verify error:num=10:certificate has expired | Expired |
| incomplete-chain.badssl.com | verify error:num=20:unable to get local issuer certificate, then num=21:unable to verify the first certificate | Missing intermediate |
| self-signed.badssl.com | verify error:num=18:self-signed certificate | Not from a trusted CA |
| wrong.host.badssl.com with -verify_hostname | verify error:num=62:hostname mismatch | Name not on the certificate |
| example.com:80, a plain-HTTP port | …:wrong version number | Not TLS |
| example.com with -noservername | sslv3 alert handshake failure … SSL alert number 40 | No SNI |
| A TLS 1.2-only OpenSSL 3 server with -tls1_3 | tlsv1 alert protocol version … SSL alert number 70 | No common version (the older tls-v1-2.badssl.com:1012 answers with alert 40 instead) |
A certificate error still shows CONNECTION ESTABLISHED in s_client, because it is a test tool and continues anyway. Browsers, curl and Cloudflare in Full (strict) mode abort at that point.
For a second opinion from outside, our SSL checker runs a TLS handshake to port 443 from our server in Amsterdam and reports expiry, hostname match, whether the chain is trusted, the certificates as sent, and TLS 1.3 and 1.2 support. Behind Cloudflare it sees Cloudflare’s edge certificate, not yours; test your server directly with s_client as shown in the Cloudflare section.
Cause 1: an expired certificate, or a wrong clock
The certificate is valid between notBefore and notAfter, judged by the client’s clock. Check the certificate on disk and what the server actually sends; the two differ when nginx was never reloaded after a renewal:
# The file nginx is configured with
sudo openssl x509 -in /etc/letsencrypt/live/example.com/fullchain.pem -noout -enddate
# Does it expire within 30 days? (prints "Certificate will expire" and exits 1)
sudo openssl x509 -in /etc/letsencrypt/live/example.com/fullchain.pem -noout -checkend 2592000
# Certbot's view of all certificates, and a renewal test against the staging CA
sudo certbot certificates
sudo certbot renew --dry-run
# Is a renewal timer scheduled? (certbot.timer, or snap.certbot.renew.timer for the snap)
systemctl list-timers | grep -i certbotIf --dry-run fails, the error names the reason, typically port 80 closed in the firewall for the HTTP challenge, or a DNS record that no longer points at this server. A renewed file on disk changes nothing until nginx reloads. Certbot reloads nginx itself when the certificate was installed with --nginx; with --webroot or --standalone add a deploy hook:
sudo tee /etc/letsencrypt/renewal-hooks/deploy/reload-nginx.sh > /dev/null <<'EOF'
#!/bin/sh
systemctl reload nginx
EOF
sudo chmod 755 /etc/letsencrypt/renewal-hooks/deploy/reload-nginx.shAutomation is not optional any more. Let’s Encrypt certificates last 90 days today; by its published plan the default drops to 64 days in February 2027 and to 45 days in February 2028.
When only one visitor sees it, check their clock, not your server. Chrome then shows Your clock is behind or Your clock is ahead with NET::ERR_CERT_DATE_INVALID, and many HTTPS sites fail at once. The fix is automatic time sync on that device; on a Linux machine, timedatectl status must show System clock synchronized: yes. A server with a wrong clock fails the same way on its own outgoing HTTPS connections: API calls, webhooks, package downloads.
Cause 2: the intermediate certificate is missing
Your certificate is signed by an intermediate CA, and the client only trusts the root above it. The server must send the intermediate along with its own certificate. Desktop browsers often hide the mistake because they have that intermediate cached or fetch it themselves, so the site looks fine in your browser while curl, Python, Java, payment callbacks and some phones fail with unable to get local issuer certificate.
With certbot the fix is one word. cert.pem holds only your certificate; fullchain.pem holds your certificate followed by the intermediate, which is what nginx needs:
ssl_certificate /etc/letsencrypt/live/example.com/fullchain.pem; # not cert.pem
ssl_certificate_key /etc/letsencrypt/live/example.com/privkey.pem;For a certificate bought elsewhere, concatenate your certificate first and the intermediate(s) after it into one file, in that order, and point ssl_certificate at it. grep -c 'BEGIN CERTIFICATE' FILE must then print at least 2. Apache 2.4.8 and later read the chain from the same file with SSLCertificateFile. If nginx refuses to start with key values mismatch, the key does not belong to the certificate; compare the two public keys:
sudo openssl x509 -in /etc/letsencrypt/live/example.com/fullchain.pem -noout -pubkey | sha256sum
sudo openssl pkey -in /etc/letsencrypt/live/example.com/privkey.pem -pubout | sha256sumCause 3: no certificate for the requested name
One IP address serves many sites, so the client names the site in the SNI field and the server picks the matching certificate. When the name matches no server_name in nginx, the default server for port 443 answers with its certificate, which the client rejects as a hostname mismatch. If that default server has ssl_reject_handshake on, as in the configuration below, the handshake fails with an unrecognized name alert instead. Check:
- Every name visitors use is in
server_nameand in the certificate:example.comandwww.example.comare two names. ThesubjectAltNameline from the command above lists what the certificate covers. - The HTTPS block also listens on IPv6 (
listen [::]:443 ssl;) if the domain has an AAAA record. Otherwise IPv6 visitors reach a different block or nothing. - On Cloudflare, the free Universal certificate covers the apex and one level of subdomain. A name like
api.dev.example.comis not covered and fails withERR_SSL_VERSION_OR_CIPHER_MISMATCHuntil you add a certificate for it at Cloudflare.
Very old clients that send no SNI at all get the default certificate or a refusal. The real -noservername result above is what Cloudflare does with them.
Cause 4: no common TLS version or cipher
Current browsers speak TLS 1.2 and 1.3 only; TLS 1.0 and 1.1 are gone. A handshake failure here has two directions:
- The server is too strict or misconfigured: a pasted cipher list from an old hardening guide that leaves only DHE or CBC suites, an
ssl_protocols TLSv1.3;that locks out TLS 1.2 clients, or an ECDSA-only certificate for clients that need RSA. Use the Mozilla intermediate settings from the configuration below. - The client is too old: embedded devices, unpatched systems and old Java or OpenSSL builds that never learned TLS 1.2 or AES-GCM. Update the client; switching TLS 1.0 back on for everyone is not a fix.
nginx logs these failures at level info, not error, so a stock error.log does not show them. For a few minutes, change the error_log line at the top of /etc/nginx/nginx.conf to error_log /var/log/nginx/error.log info;, reload, reproduce, and look for lines such as SSL_do_handshake() failed (SSL: error:0A000102:SSL routines::unsupported protocol) or …::no shared cipher. Set it back afterwards; info is noisy.
Cause 5: the port does not speak TLS
If something answers in plain HTTP where the client expects TLS, the client reads the HTTP text as a malformed TLS record. curl reports it like this, real output from connecting with HTTPS to port 80:
curl: (35) OpenSSL/3.0.22: error:0A00010B:SSL routines::wrong version numberFirefox shows SSL_ERROR_RX_RECORD_TOO_LONG, Chrome ERR_SSL_PROTOCOL_ERROR. The cause is almost always a listen 443; without the ssl parameter, an app on its own port (:8443, :3000) that serves plain HTTP, or a firewall forward to the wrong port. The reverse mistake, HTTP sent to a TLS port, makes nginx answer 400 The plain HTTP request was sent to HTTPS port.
Rare: something in the network
If the handshake works from one network and hangs or resets from another, look between the two. TLS-inspecting firewalls and antivirus products replace the certificate with their own and break on anything they do not understand. A path MTU problem can drop the large server packet that carries the certificate chain: the connection opens, the ClientHello goes out, and nothing comes back. ping -M do -s 1472 example.com on Linux sends a full 1500-byte packet that may not be fragmented; if that fails while smaller sizes work, the path is the problem, not TLS.
Cloudflare error 525 and 526
Behind Cloudflare there are two handshakes: visitor to Cloudflare, and Cloudflare to your server. Error 525 and 526 are about the second one and only occur in the SSL/TLS modes Full and Full (strict).
- 525 SSL handshake failed: Cloudflare reached port 443 but the TLS handshake with your server failed. Causes 3, 4 and 5 above: no certificate for the hostname Cloudflare sends as SNI, no common cipher, or plain HTTP on 443.
- 526 Invalid SSL certificate: only in Full (strict). The handshake worked, but the certificate is expired, self-signed, issued for another name, or sent without its intermediate. A Let’s Encrypt certificate passes, and so does a Cloudflare Origin CA certificate, which Cloudflare trusts although browsers do not.
Test your server exactly the way Cloudflare connects: to its IP, with the hostname as SNI. YOUR_SERVER_IP is the origin address from your hosting panel:
openssl s_client -connect YOUR_SERVER_IP:443 -servername example.com \
-verify_hostname example.com -verify_return_error -brief </dev/null
# With a Cloudflare Origin CA certificate, trust Cloudflare's origin root for the test
# (origin_ca_ecc_root.pem instead if you created an ECC certificate)
curl -fsSO https://developers.cloudflare.com/ssl/static/origin_ca_rsa_root.pem
openssl s_client -connect YOUR_SERVER_IP:443 -servername example.com \
-CAfile origin_ca_rsa_root.pem -verify_hostname example.com -verify_return_error -brief </dev/nullA clean Verification: OK here and a 526 at Cloudflare means Cloudflare reaches something else: check that the proxied DNS record points at the IP you tested, and that you tested the hostname that fails, subdomain included. Switching the mode down to Full makes a 526 disappear but stops checking the certificate; Flexible removes encryption to your server entirely and, with an HTTPS redirect on the server, gives you ERR_TOO_MANY_REDIRECTS. The neighbouring codes are not about TLS: a 521 means your server refused the connection on 443, a 522 that it never answered, and a 520 that the response after the handshake broke off or made no sense.
A correct nginx TLS configuration
This covers the server-side causes in one place. The protocol and cipher settings go into the http block of /etc/nginx/nginx.conf, not into one site: the nginx versions in Debian 12 and 13 and Ubuntu 24.04 negotiate the TLS version before they know which server_name the client wants, so a ssl_protocols line inside a non-default server block has no effect there. Only nginx 1.29.2 and later honour it per server. Debian 12 and Ubuntu 24.04 ship ssl_protocols TLSv1 TLSv1.1 TLSv1.2 TLSv1.3; and ssl_prefer_server_ciphers on; there. Replace those two lines rather than adding new ones: nginx merges a second ssl_protocols line into the first, so TLS 1.0 would stay on with only a duplicate value warning, and a second ssl_prefer_server_ciphers makes nginx -t fail.
# Mozilla "intermediate": TLS 1.2 and 1.3 only
ssl_protocols TLSv1.2 TLSv1.3;
ssl_ciphers ECDHE-ECDSA-AES128-GCM-SHA256:ECDHE-RSA-AES128-GCM-SHA256:ECDHE-ECDSA-AES256-GCM-SHA384:ECDHE-RSA-AES256-GCM-SHA384:ECDHE-ECDSA-CHACHA20-POLY1305:ECDHE-RSA-CHACHA20-POLY1305;
ssl_prefer_server_ciphers off;
ssl_session_cache shared:SSL:10m;
ssl_session_timeout 1d;The site file then carries the certificate with its full chain, listens on IPv4 and IPv6, and adds a default server that refuses names you do not host instead of handing out the wrong certificate:
# Names you do not serve get no certificate at all, not a wrong one
server {
listen 443 ssl default_server;
listen [::]:443 ssl default_server;
ssl_reject_handshake on;
}
server {
listen 443 ssl;
listen [::]:443 ssl;
server_name example.com www.example.com;
# fullchain.pem = your certificate followed by the intermediate(s)
ssl_certificate /etc/letsencrypt/live/example.com/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/example.com/privkey.pem;
root /var/www/example.com;
index index.html;
}
server {
listen 80;
listen [::]:80;
server_name example.com www.example.com;
return 301 https://$host$request_uri;
}Replace the names, paths and root. The ssl_reject_handshake block (nginx 1.19.4 and later) must be the only default_server on port 443; it also refuses clients that send no SNI, which today means almost nobody. Debian 13 already ships the TLS 1.2/1.3 setting. Then test and reload:
sudo nginx -t && sudo systemctl reload nginxnginx -t already catches a missing certificate file, a listen … ssl without ssl_certificate, and a key that does not match. Run the s_client test again afterwards, from outside.
Getting the certificate with certbot and putting an app behind this block is covered step by step in our nginx reverse proxy with Let’s Encrypt guide.
On a VMHeaven Ubuntu VPS you have root for all of it, and if a firewall or nginx change cuts you off, the browser VNC console in the panel still reaches the server.
Frequently asked
How do I check why an SSL handshake fails?
Run 'openssl s_client -connect example.com:443 -servername example.com -brief </dev/null'. The error line names the cause: 'certificate has expired', 'unable to verify the first certificate' for a missing intermediate, 'wrong version number' for plain HTTP on the port, or a handshake failure alert for no common TLS version or cipher.
What is the difference between Cloudflare error 525 and 526?
Both concern the connection from Cloudflare to your server in the SSL/TLS modes Full or Full (strict). 525 means the TLS handshake itself failed, for example no certificate for the hostname or plain HTTP on port 443. 526 means the handshake worked but Full (strict) rejected the certificate as expired, self-signed or issued for another name.
Can a wrong computer clock cause SSL errors?
Yes. Certificates are checked against the client's own clock, so a clock that is far off makes valid certificates look expired or not yet valid. Chrome then shows NET::ERR_CERT_DATE_INVALID, often with 'Your clock is behind'. Turn on automatic time sync and reload.