SSL handshake failed
The client and server could not agree on a TLS version, cipher or certificate. Four root causes, each identified by its alert number, measured against live edges and badssl.com.
"SSL handshake failed" means the client and the server could not agree on a protocol version, a cipher, or a certificate before any HTTP was exchanged, so the connection was abandoned. It has four root causes, and the fastest way to tell them apart is the TLS alert number the failing side sent, which every client will show you if you ask.
The phrase is a catch-all. Browsers, curl, Java, Node and Cloudflare all print it (or a cousin of it) for failures that have nothing in common except the moment they happen, so the first job is to sort the message into one of two families. Everything below was measured on 2026-09-06 against badssl.com's deliberately broken hosts and against live production edges.
Domainee is a custom domains API for SaaS with a native MCP server — 50 domains and 100 GB free.
Two families hiding under one message
A TLS handshake can fail in two places. Either the two sides never agree on how to talk (version, cipher, or which hostname is meant), or they agree and then the client rejects the certificate it was handed. Clients report the two families differently, and the split is the most useful thing on this page:
| What actually happened | curl exit code | Chrome | Firefox | Java |
|---|---|---|---|---|
| Handshake refused (version, cipher, SNI) | 35 | ERR_SSL_VERSION_OR_CIPHER_MISMATCH, ERR_SSL_PROTOCOL_ERROR | SSL_ERROR_NO_CYPHER_OVERLAP, SSL_ERROR_HANDSHAKE_FAILURE_ALERT | SSLHandshakeException: Received fatal alert: handshake_failure |
| Certificate rejected after a good handshake | 60 | ERR_CERT_DATE_INVALID, ERR_CERT_COMMON_NAME_INVALID, ERR_CERT_AUTHORITY_INVALID | SEC_ERROR_EXPIRED_CERTIFICATE, SSL_ERROR_BAD_CERT_DOMAIN, SEC_ERROR_UNKNOWN_ISSUER | SSLHandshakeException: PKIX path building failed |
The curl codes are the cleanest test. Exit 35 is a real handshake failure; exit 60 means the handshake completed and verification failed, which is a certificate problem with a different fix. Cloudflare's 525 belongs to the first family and its 526 to the second, which is why they are separate codes.
Read the alert number first
When the refusing side is polite, it sends a TLS alert before closing. OpenSSL prints it as SSL alert number N, and the number is the diagnosis:
| Alert | Name | Means |
|---|---|---|
| 40 | handshake_failure | No cipher suite in common, or a parameter the server will not accept |
| 42-46 | bad_certificate, certificate_revoked, certificate_expired, certificate_unknown | The client's certificate was rejected (mutual TLS only) |
| 48 | unknown_ca | Issuer not trusted by whoever sent the alert |
| 70 | protocol_version | No TLS version in common |
| 71 | insufficient_security | The offered ciphers are all too weak |
| 112 | unrecognized_name | The server does not know the SNI hostname |
| 116 | certificate_required | Server demanded a client certificate and got none |
Two of those came up repeatedly in testing, and one failure mode sends no alert at all.
Cause 1: no protocol version in common (alert 70)
Forcing TLS 1.0 or 1.1 against modern edges produced the same result every time:
$ openssl s_client -connect domainee.dev:443 -servername domainee.dev -tls1
tlsv1 alert protocol version ... SSL alert number 70
vercel.com and github.com answered identically. cloudflare.com did not: it negotiated TLS 1.0 with ECDHE-RSA-AES128-SHA, because Cloudflare's minimum TLS version defaults to 1.0 and the site keeps that default. So an old client that works on one site and fails on another is not misconfigured on the failing site; the failing site is the one with a sane minimum.
The reverse case is a server stuck on an old version. tls-v1-0.badssl.com only speaks TLS 1.0, and macOS curl (built on SecureTransport) returned HTTP 200 from it, while Chrome, which dropped TLS 1.0 and 1.1 in version 84, shows ERR_SSL_VERSION_OR_CIPHER_MISMATCH. Same server, one client succeeds, one fails.
A third shape of alert 70 is not a TLS problem at all. Pointing https:// at a port that speaks plain HTTP (openssl s_client -connect example.com:80) produced tlsv1 alert protocol version on this machine and SSL_ERROR_RX_RECORD_TOO_LONG in Firefox, because the client tried to parse an HTTP response as a TLS record. If you see that, check the port before anything else.
Cause 2: no cipher in common (alert 40)
Offering a single obsolete cipher to a modern edge is the canonical alert 40:
$ openssl s_client -connect cloudflare.com:443 -servername cloudflare.com -tls1_2 -cipher DES-CBC3-SHA
sslv3 alert handshake failure ... SSL alert number 40
Again the client decides. rc4.badssl.com offers only RC4, and macOS curl connected with ECDHE-RSA-RC4-SHA and got a 200; every current browser refuses RC4 outright. dh480.badssl.com uses a 480-bit Diffie-Hellman group, and there the client aborted on its own with bad dh p length and never sent an alert, which is the failure mode that shows up in logs as a connection reset with no explanation.
The practical rule: if only some clients fail, list which ones. Old Java runtimes, embedded devices, and anything pinned to a single cipher are the usual suspects, and the fix is on their side, not by re-enabling RC4.
Cause 3: the edge does not know the hostname
This is the one a custom domain platform meets constantly, and the one the generic guides skip. Every request to a shared IP carries the intended hostname in the SNI extension, and the edge uses it to pick a certificate. What happens when the name is missing or unknown depends entirely on the edge, measured live:
| Edge | No SNI at all | SNI for a hostname it does not host |
|---|---|---|
| Vercel | Serves a placeholder certificate, no-sni.vercel-infra.com | Closes the connection mid-handshake. curl reports exit 35, SSL_ERROR_SYSCALL, zero bytes read |
| Railway (the edge in front of this site) | Serves its wildcard, *.up.railway.app | Serves the same wildcard: handshake succeeds, then a name mismatch, curl exit 60 |
| Cloudflare | Serves the certificate of whichever site the IP belongs to | Served a certificate for a different site sharing the IP, then a name mismatch |
So an unknown hostname produces a genuine "SSL handshake failed" on one platform and a certificate-name error on the other two. If your customer says handshake failed and your customer is on your Vercel-style edge, the most likely explanation is that their CNAME is already pointing at you and their certificate does not exist yet. HTTP-01 cannot issue before DNS resolves to you, so there is always a window; the runbook for customer domain issues walks the order of checks.
Two related traps. A hostname that resolves to your old IP because DNS has not propagated gets whatever certificate lives at that old address, which is the "it works for me, fails for them" pattern; the propagation checker shows which resolvers still hold the old answer. And a client that sends no SNI at all (very old Java, some monitoring probes, Python 2 era libraries) will get the fallback certificate everywhere, so their failure is real but not yours to fix.
Cause 4: the certificate was rejected
The second family. The handshake itself completed and the client refused what it saw:
| Test host | curl said | openssl verify code |
|---|---|---|
expired.badssl.com | certificate has expired | 10 |
wrong.host.badssl.com | no alternative certificate subject name matches target host name | 62 |
self-signed.badssl.com | self signed certificate | 18 |
untrusted-root.badssl.com | self signed certificate in certificate chain | 19 |
incomplete-chain.badssl.com | macOS curl: 200; OpenSSL: unable to get local issuer certificate | 20, then 21 |
The incomplete-chain row is the one that generates support tickets. The server sent only its leaf certificate and not the intermediate that links it to a trusted root. Browsers and macOS fetch the missing intermediate themselves using the AIA URL in the certificate, so the site looks fine in Chrome and Safari; OpenSSL-based clients, Android, and most server-side HTTP libraries do not, and fail. The SSL certificate checker shows the chain as served, which is what those clients see.
Each rejection has its own page: expired, name mismatch, self-signed, and an issuer that is not a trusted CA. A SAN certificate that simply omits the hostname is the most common cause of the second one on multi-domain setups.
Cloudflare 525 is the same failure, one hop back
Cloudflare's Error 525: SSL handshake failed is not about the browser at all. Cloudflare accepted the browser's request and then failed its own handshake with your origin, so everything above applies with Cloudflare as the client. It only happens in Full or Full (strict) mode, and Cloudflare's documentation lists exactly four origin-side causes: no valid certificate installed, port 443 not open, no SNI support at the origin, and no cipher suite in common with Cloudflare.
Two details from the same document worth knowing. Intermittent 525s are almost always the origin resetting connections under load rather than a bad certificate, and the place to look is the origin's TLS error log at the matching timestamps. And Cloudflare's Origin Analytics reports these as originResponseStatus of 0, which is the only place a 525 shows up separately from a 5xx the origin actually sent. 526 is the second family: the handshake worked and Cloudflare rejected the origin certificate, so it is fixed with a valid certificate or an Origin CA certificate, never with cipher changes.
A diagnosis order that works
- Get the exit code.
curl -v https://host/and read the last lines. Exit35is a handshake refusal,60is verification. Everything else on this page branches from that. - Get the alert.
openssl s_client -connect host:443 -servername hostprintsSSL alert number Nwhen there is one. No alert and zero bytes read means the server closed the socket, which points at an unknown SNI name or a client-side abort. - Compare with and without SNI. Run the same command with
-servernameset to a name the server definitely hosts. If that works, the hostname is the problem, not TLS. - Look at the chain, not the leaf.
openssl s_client -showcertslists what the server actually sends; one certificate where two are expected is the incomplete chain. - Test from a second client. The measurements above show macOS
curlaccepting three configurations that browsers reject. A failure that only one client reproduces is that client's policy. - Check the port. Alert 70 against a port that serves plain HTTP is a routing mistake; the website status checker and HTTP header checker will show what actually answers there.
In a custom domain platform
If you terminate TLS for customer hostnames, you are the edge in the table above, and you choose what an unknown name gets. Serving a clearly named fallback certificate (as Vercel does for the no-SNI case) turns a silent handshake drop into an error that names the problem, and it lets your own probes distinguish "not provisioned yet" from "broken". Keep the minimum at TLS 1.2 and let the alert 70s happen; the clients that send them are the ones you do not want. And alert on failed renewals rather than on approaching expiry, because a lapsed renewal surfaces as this error on the customer's domain, not yours. The guide to how SSL works for custom domains covers provisioning, and monitoring certificates at scale covers catching the lapse before the customer does.
FAQ
Is "SSL handshake failed" a client problem or a server problem? Either, and the alert number tells you which side refused. Alert 70 or 40 sent by the server means the client offered versions or ciphers the server will not accept; the same alerts from the client mean the reverse. A certificate rejection is always the client's verdict on what the server sent, so the fix is on the server.
Why does the site work in Chrome but fail in my script or app?
Usually an incomplete certificate chain. Browsers fetch a missing intermediate on their own; OpenSSL-based clients, Android and most HTTP libraries do not and report unable to get local issuer certificate. Serve the full chain and both work.
What does SSL alert number 40 mean?
handshake_failure: no cipher suite or key-exchange parameter in common. In testing it came from offering a single obsolete cipher to a modern edge. Check what the client offers before touching the server's cipher list.
What does SSL alert number 70 mean?
protocol_version: no TLS version in common. Modern edges send it to TLS 1.0 and 1.1 clients. It also appears when https:// is pointed at a port speaking plain HTTP, so confirm the port before assuming a TLS misconfiguration.
Why do my customers get a handshake error before their custom domain is set up? Their CNAME already resolves to your edge but their certificate has not been issued yet, so the edge has nothing to present for that SNI name. Some edges close the connection at that point, which surfaces as "SSL handshake failed". Once the certificate exists the error clears without any change on the customer's side.
Is Cloudflare 525 the same as a browser SSL handshake failure? Same mechanism, different client. Cloudflare is the client and your origin is the server. The four documented causes are a missing origin certificate, port 443 closed, no SNI support at the origin, and no cipher in common with Cloudflare.