'SSL handshake failed': what causes it and how to fix it
An "SSL handshake failed" error means the client and server could not agree on the terms of an encrypted connection, so the connection is dropped before a single byte of your request or response is exchanged. Because the handshake happens first, the failure is opaque: you get a generic error with no HTTP status, no page, and often no useful detail. This guide walks through what the TLS handshake actually does, how the same root cause shows up differently across curl, nginx, Java, browsers, and openssl, and the eight common causes with a diagnosis and fix for each.
What the TLS handshake is, and why it fails first
Before any HTTP data flows over HTTPS, the client and server run a TLS handshake to negotiate how the connection will be secured. In broad strokes the client says hello with the TLS versions and cipher suites it supports plus the hostname it wants (SNI); the server replies with its chosen version and cipher, and its certificate chain; the client validates that chain and they derive shared keys. Only after all of that succeeds does the actual request get sent.
If any step can't be completed — no shared protocol version, no shared cipher, a certificate the client won't trust — there is nothing to fall back to. TLS fails closed. That is why a handshake failure produces no partial response: the secure channel was never established, so the application layer never ran. Diagnosing it means reasoning about the negotiation, not about your app.
How the same failure looks in different clients
The underlying problem is the same; the wording depends entirely on which client hit it. Recognizing your symptom is the first step.
- curl:
curl: (35) error:0A000410:SSL routines::sslv3 alert handshake failure, or the bluntercurl: (35) OpenSSL/3.x: error:... SSL_ERROR_SYSCALL. Exit code 35 is curl's "problem during the SSL/TLS handshake" code. - nginx (as a client, e.g. proxying upstream):
SSL_do_handshake() failed (SSL: error:... sslv3 alert handshake failure) while SSL handshaking to upstreamin the error log. - Java:
javax.net.ssl.SSLHandshakeException, often with a nested cause likeReceived fatal alert: handshake_failure,No appropriate protocol, orPKIX path building failed: ... unable to find valid certification path. - Browsers: Chrome shows
ERR_SSL_PROTOCOL_ERRORorERR_SSL_VERSION_OR_CIPHER_MISMATCH; Firefox showsSSL_ERROR_NO_CYPHER_OVERLAPorMOZILLA_PKIX_ERROR_*. - openssl s_client: the connection ends early, often with
write:errno=104(connection reset),alert handshake failure, or averify error:num=...line followed byVerify return code: 21 (unable to verify the first certificate).
All of these mean the handshake never completed. The rest of this guide is about finding which step broke.
Cause 1: protocol version mismatch
Modern servers reject TLS 1.0 and 1.1, and the newest servers may require TLS 1.2 as a floor or speak only TLS 1.2/1.3. If the client supports only an older version (or, rarely, the reverse — an ancient server that can't do 1.2), there is no common protocol and the handshake fails immediately. In Java you'll see No appropriate protocol (protocol is disabled or cipher suites are inappropriate); in Firefox, SSL_ERROR_UNSUPPORTED_VERSION.
Diagnose by forcing a specific version with openssl s_client:
# Does the server accept TLS 1.2?
openssl s_client -connect example.com:443 -servername example.com -tls1_2 </dev/null
# Does it accept TLS 1.3?
openssl s_client -connect example.com:443 -servername example.com -tls1_3 </dev/null
If -tls1_2 and -tls1_3 both succeed but your client still fails, your client is offering only TLS 1.0/1.1. If both fail, the server isn't offering a version your modern openssl will use.
Fix: upgrade the client's TLS library so it can negotiate TLS 1.2/1.3 (old OpenSSL, old Java, or a pinned legacy runtime are the usual culprits), or fix the server to offer a current version. Do not "fix" it by re-enabling TLS 1.0/1.1 on the server — that reopens known weaknesses. For background on the protocols themselves, see SSL vs TLS: the difference.
Cause 2: cipher suite mismatch
Even when the protocol version matches, the client and server each carry a list of cipher suites. If the lists don't intersect, the handshake aborts. This is common when a hardened server allows only a few strong AEAD ciphers and an old client offers only legacy ones. Chrome reports ERR_SSL_VERSION_OR_CIPHER_MISMATCH; Firefox reports SSL_ERROR_NO_CYPHER_OVERLAP.
Diagnose by listing what the server will negotiate and probing a specific suite:
# What cipher does the server pick for a normal client?
openssl s_client -connect example.com:443 -servername example.com </dev/null 2>/dev/null \
| grep -E 'Cipher|Protocol'
# Try to force one specific TLS 1.2 cipher
openssl s_client -connect example.com:443 -servername example.com \
-cipher 'ECDHE-RSA-AES128-GCM-SHA256' </dev/null
A no cipher match / sslv3 alert handshake failure here means the server won't accept that suite.
Fix: align the two. Usually that means broadening the server's cipher list to include at least one modern suite the client supports, or upgrading the client. If you tightened a server config and clients started failing, you cut too deep — add back a widely-supported ECDHE AEAD suite. Beware ECDSA-vs-RSA mismatches too: a client that only supports RSA key exchange can't use an ECDSA-only certificate.
Cause 3: expired (or not-yet-valid) certificate — often client clock skew
If the certificate is outside its validity window, well-behaved clients refuse it during the handshake. openssl prints verify error:num=10:certificate has expired and Java surfaces cert_has_expired. The subtle version of this is client clock skew: the cert is perfectly valid, but the client's clock is wrong, so the cert looks expired or not-yet-valid from its point of view. A server with a clock set far in the past will see an otherwise-valid cert as notBefore in the future.
Diagnose by reading the validity window and comparing it to a trusted clock:
echo | openssl s_client -connect example.com:443 -servername example.com 2>/dev/null \
| openssl x509 -noout -dates
date -u
notBefore=Jan 1 00:00:00 2026 GMT
notAfter=Apr 1 23:59:59 2026 GMT
If notAfter is genuinely in the past, the cert expired. If the dates look fine but only one machine rejects the cert, suspect that machine's clock.
Fix: if the certificate truly expired, renew and reload it — see what cert_has_expired means and how to fix it, the deeper expired-certificate guide, and how to renew an SSL certificate. If the cert is valid and the clock is wrong, fix the clock: enable NTP (timedatectl set-ntp true on systemd hosts) so it stays correct.
Cause 4: untrusted or incomplete chain (missing intermediate)
A server must send the leaf certificate and every intermediate that links it to a trusted root. When an intermediate is missing, browsers often paper over it with cached or AIA-fetched certs, but curl, Java, Go, and mobile clients frequently can't, so they reject the chain mid-handshake. openssl shows verify error:num=21:unable to verify the first certificate (incomplete chain) or num=20:unable to get local issuer certificate; Java throws PKIX path building failed.
Diagnose by dumping the chain the server actually serves:
openssl s_client -connect example.com:443 -servername example.com -showcerts </dev/null
Count the certificates in the Certificate chain block. If it stops at the leaf (s: and i: showing only your domain and its issuer with no intermediate following), the intermediate is missing.
Fix: configure the server to present the full chain (with Let's Encrypt/certbot, point your config at fullchain.pem, not cert.pem). For the full mental model, read the SSL certificate chain explained and how to fix an untrusted certificate; for the specific error, see unable_to_verify_leaf_signature.
Cause 5: SNI missing or wrong
Most hosts serve many certificates from one IP and choose which to present based on the SNI (Server Name Indication) value in the client hello. If the client sends no SNI, or the wrong one, a multi-tenant host hands back its default certificate — which won't match the hostname you wanted, causing a name-mismatch failure or a wholesale handshake abort on stricter setups.
Diagnose by toggling -servername:
# Correct SNI: should return your certificate
openssl s_client -connect example.com:443 -servername example.com </dev/null 2>/dev/null \
| openssl x509 -noout -subject
# No SNI at all: many hosts return a different/default cert
openssl s_client -connect example.com:443 </dev/null 2>/dev/null \
| openssl x509 -noout -subject
If the subject changes (or the no-SNI case fails) the server is SNI-dependent, and any client not sending SNI will get the wrong cert.
Fix: make sure the client sends SNI — virtually every modern HTTP client does this automatically when you connect by hostname, so this usually surfaces when something connects by IP, uses a very old library, or has SNI disabled. On the server side, define an explicit default server/vhost so unmatched SNI gets a sane certificate rather than a confusing one.
Cause 6: a client certificate is required but not presented (mutual TLS)
In mutual TLS (mTLS), the server requires the client to present a certificate too. If your client doesn't send one, or sends one the server won't accept, the server aborts the handshake. openssl shows alert handshake failure or, more tellingly, Acceptable client certificate CA names in the verbose output; nginx logs client sent no required SSL certificate or 400 No required SSL certificate was sent.
Diagnose by checking whether the server asks for a client cert:
openssl s_client -connect example.com:443 -servername example.com </dev/null 2>&1 \
| grep -A3 'Acceptable client certificate CA names'
If that block appears, the server is requesting client authentication.
Fix: present the expected client certificate and key:
openssl s_client -connect example.com:443 -servername example.com \
-cert client.crt -key client.key </dev/null
In your application, configure the same client cert/key pair in the HTTP client. If you didn't expect mTLS at all, confirm you're hitting the right endpoint — internal mTLS gateways are easy to reach by accident.
Cause 7: interception by a proxy, firewall, or antivirus
Corporate proxies, "next-gen" firewalls, and some antivirus products perform TLS inspection: they terminate your TLS connection, re-sign traffic with their own CA, and re-encrypt to the origin. If your client doesn't trust the interceptor's CA, validation fails mid-handshake; if the interceptor mangles the connection, you get resets that look like SSL_ERROR_SYSCALL or curl: (35) with no clean alert.
Diagnose by inspecting the issuer of the certificate you actually receive:
echo | openssl s_client -connect example.com:443 -servername example.com 2>/dev/null \
| openssl x509 -noout -issuer
If the issuer is your corporate proxy or antivirus vendor instead of a public CA (Let's Encrypt, DigiCert, Google Trust Services, etc.), traffic is being intercepted. Comparing the result on a corporate network versus a phone hotspot makes this obvious.
Fix: install the interceptor's root CA into the client's trust store if the inspection is sanctioned, or add a bypass for the destination. If it's unsanctioned (some antivirus "HTTPS scanning" features), disabling that feature restores a direct handshake.
Cause 8: connecting to a non-TLS port or the wrong port
If you point a TLS client at a port that speaks plain TCP or HTTP, there's no server hello to receive — the handshake "fails" because the other end never spoke TLS. A classic case is hitting port 80 (plain HTTP) expecting HTTPS, or a database/SMTP port that uses STARTTLS rather than implicit TLS. openssl typically shows wrong version number (error:...:wrong version number), which almost always means "this port isn't TLS."
Diagnose by testing the port directly:
openssl s_client -connect example.com:443 -servername example.com </dev/null
A wrong version number error here is the tell. If the service uses STARTTLS (SMTP, IMAP, PostgreSQL), you must negotiate it explicitly:
openssl s_client -starttls smtp -connect mail.example.com:587 \
-servername mail.example.com </dev/null
Fix: connect to the correct TLS port (443 for HTTPS, 465 for implicit SMTPS, etc.), or use the right STARTTLS flag for protocols that upgrade an initially-plaintext connection.
A general diagnostic recipe
When you don't yet know which of the above you're facing, work outward from a single s_client call. Run the baseline first:
openssl s_client -connect example.com:443 -servername example.com </dev/null
Read the output top to bottom:
wrong version number→ wrong/non-TLS port (cause 8).sslv3 alert handshake failure/no cipher match/no protocols available→ version or cipher mismatch (causes 1, 2). Re-run with-tls1_2,-tls1_3, and-cipher ...to pin it down.verify error:num=10→ expired (cause 3). Check-datesand your clock.verify error:num=20/21→ broken or incomplete chain (cause 4). Re-run with-showcerts.- Wrong
subjectreturned, or success only with-servername→ SNI (cause 5). Acceptable client certificate CA namesthen a reset → mTLS (cause 6).- An unexpected issuer on the cert you receive → interception (cause 7).
For a full reference of these flags and how to read every section of s_client output, see check an SSL certificate with openssl. To get a neutral, outside-your-network second opinion that also validates the chain and SNI, run the hostname through the SSL check tool — that's especially useful for telling apart a server-side problem from a local proxy or trust-store issue.
Monitor it automatically
Most handshake failures are avoidable: an expired cert, a chain that lost its intermediate after a renewal, or a TLS-config change that dropped the last cipher an important client could use. SSLNudge checks your certificates every day — expiry, chain completeness, hostname/SAN match, and protocol negotiation — and alerts you well before a quiet regression turns into a wall of "SSL handshake failed" errors for your users. Sign in to start monitoring and let the daily check catch these before they reach production.
Stop tracking expiry dates by hand
SSLNudge checks your certificates daily and alerts you before they expire.