An SSL handshake failure occurs when the client (browser or API consumer) and server can't agree on the parameters needed to establish an encrypted connection. The handshake itself is a multi-step negotiation — protocol version, cipher suite, certificate verification — and it can fail at any of those steps. The exact cause determines the fix.
Step 1: Diagnose with OpenSSL
OpenSSL is the fastest way to pinpoint the cause. Run these commands and compare output:
Basic Handshake Test
openssl s_client -connect yourdomain.com:443 -servername yourdomain.com
SSL-Session:
Protocol : TLSv1.3 ← good
Cipher : TLS_AES_256_GCM_SHA384 ← negotiated cipher
Verify return code: 0 (ok) ← certificate chain is valid
Verify return code: 20 (unable to get local issuer certificate) ← chain incomplete
Verify return code: 10 (certificate has expired) ← cert expired
Test Specific TLS Versions
openssl s_client -connect yourdomain.com:443 -tls1_3 -servername yourdomain.com
openssl s_client -connect yourdomain.com:443 -tls1_2 -servername yourdomain.com
Test Specific Cipher Suites
nmap --script ssl-enum-ciphers -p 443 yourdomain.com
openssl s_client -connect yourdomain.com:443 -cipher "ECDHE-RSA-AES256-GCM-SHA384"
$ openssl s_client -connect yourdomain.com:443 -tls1_3
140584647214912:error:1409442E:SSL routines:ssl3_read_bytes:
tlsv1 alert protocol version:../ssl/record/rec_layer_s3.c:1544:
SSL alert number 70
Fix #1: Protocol Mismatch (Server Enables Old TLS Only)
If your server is still on TLS 1.0 or 1.1 only, modern browsers will refuse to connect. The fix is enabling TLS 1.2 and TLS 1.3 in your server config.
Apache — Enable TLS 1.2 + 1.3
SSLProtocol -all +TLSv1.2 +TLSv1.3
SSLCipherSuite ECDHE-ECDSA-AES128-GCM-SHA256:ECDHE-RSA-AES128-GCM-SHA256:\
ECDHE-ECDSA-AES256-GCM-SHA384:ECDHE-RSA-AES256-GCM-SHA384:\
TLS_AES_256_GCM_SHA384:TLS_CHACHA20_POLY1305_SHA256
SSLHonorCipherOrder off
sudo apachectl configtest && sudo systemctl reload apache2
Nginx — Enable TLS 1.2 + 1.3
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;
ssl_prefer_server_ciphers off;
sudo nginx -t && sudo systemctl reload nginx
Fix #2: Incomplete Certificate Chain
The server sends your certificate but not the intermediate certificate the CA issued it from. The client can't build a trusted path to a root CA.
openssl s_client -connect yourdomain.com:443 -servername yourdomain.com 2>&1 | grep -E "Chain|issuer|subject"
CONNECTED(00000003)
depth=0 CN=yourdomain.com ← only server cert, no intermediate
depth=2 CN=ISRG Root X1
depth=1 CN=R3, O=Let's Encrypt
depth=0 CN=yourdomain.com
Fix: Create a Combined Certificate File
cat yourdomain.crt intermediate.crt > yourdomain-bundle.crt
SSLCertificateFile /etc/ssl/yourdomain-bundle.crt
ssl_certificate /etc/ssl/yourdomain-bundle.crt;
Fix #3: Clock Skew
If the client's system clock is more than a few minutes off, the TLS library will reject the certificate as expired or not yet valid. This is common on servers without NTP configured or VMs that lost time sync after a suspend/resume.
openssl s_client -connect yourdomain.com:443 2>/dev/null | openssl x509 -noout -dates
notBefore=Jun 15 00:00:00 2026 GMT
notAfter=Sep 13 23:59:59 2026 GMT
sudo timedatectl set-ntp true
timedatectl status
Fix #4: Cipher Mismatch Between Client and Server
If your server only allows old cipher suites (like RC4 or 3DES) and the client has deprecated them, no common cipher is available. The solution is updating to modern AEAD ciphers.
nmap --script ssl-enum-ciphers -p 443 yourdomain.com 2>/dev/null | grep -E "TLS_|ciphers"
# TLS_AES_256_GCM_SHA384 (TLS 1.3 only)
# TLS_CHACHA20_POLY1305_SHA256 (TLS 1.3 only)
# ECDHE-RSA-AES256-GCM-SHA384 (TLS 1.2)
# ECDHE-RSA-AES128-GCM-SHA256 (TLS 1.2)
# Remove these deprecated ciphers if present:
# RC4-SHA, DES-CBC3-SHA, AES128-SHA (no ECDHE = no forward secrecy)
Verify the Fix
After any configuration change, confirm the handshake now completes successfully:
$ openssl s_client -connect yourdomain.com:443 -servername yourdomain.com < /dev/null 2>&1 | tail -6
New, TLSv1.3, Cipher is TLS_AES_256_GCM_SHA384
Server public key is 2048 bit
Post-Handshake Auth: No
Compression: NONE
Expansion: NONE
Verify return code: 0 (ok)
Tip: Run your domain through the
SSL Checker after making changes. It tests TLS protocol support, cipher strength, certificate validity, and chain completeness in one pass without needing shell access.
FAQs
- The handshake fails only for some users, not all. Why?
Different client environments support different TLS versions and ciphers. If old iOS or Android devices fail but Chrome on desktop works, your server likely supports TLS 1.2 but is missing some cipher suites that older mobile TLS stacks require. Enabling ECDHE-RSA-AES128-GCM-SHA256 and ECDHE-RSA-AES256-GCM-SHA384 covers the vast majority of clients.
- The SSL Checker shows everything is fine but my Java application still fails the handshake. Why?
Java maintains its own TLS trust store separate from the OS. Older JDK versions (before JDK 11) don't trust Let's Encrypt's intermediate CA (R3). Update your JDK to 11+, or manually import the Let's Encrypt Root CA into your Java keystore with keytool -import.
- Handshake fails on port 443 but works on port 8443. What does that mean?
The two ports likely have different SSL configurations on your server. Check that port 443's VirtualHost or server block has the same SSL directives as 8443, including the SSLProtocol, SSLCipherSuite, and certificate path directives.
Diagnosing SSL Handshake Failures on Windows
Windows Server
IIS / PowerShell
SSL handshake failures on Windows generate Event ID 36888 (fatal alert) or 36874 (protocol version) in the Windows Event Log. Check these first before running network-level diagnostics.
Check Windows Event Log for TLS Errors
# Filter Event Log for Schannel errors (run in elevated PowerShell)
Get-EventLog -LogName System -Source "Schannel" -Newest 20 | `
Where-Object {$_.EntryType -eq "Error"} | `
Select-Object TimeGenerated, EventID, Message | `
Format-List
# Key Event IDs:
# 36888 — Fatal SSL alert received (e.g., handshake failure, protocol version)
# 36874 — TLS protocol version not supported by the client
# 36882 — Certificate not found / certificate mismatch
Test TLS Handshake with PowerShell
# Test TLS handshake to a remote server
$domain = "yourdomain.com"
$port = 443
try {
$tcp = New-Object System.Net.Sockets.TcpClient($domain, $port)
$ssl = New-Object System.Net.Security.SslStream($tcp.GetStream(), $false,
({param($s,$c,$ch,$e) $true})) # Accept any cert for diagnostics
$ssl.AuthenticateAsClient($domain)
Write-Host "Handshake succeeded"
Write-Host "TLS version: $($ssl.SslProtocol)"
Write-Host "Cipher: $($ssl.CipherAlgorithm)"
Write-Host "Cert subject: $($ssl.RemoteCertificate.Subject)"
$ssl.Close()
} catch {
Write-Host "Handshake FAILED: $_"
}
$tcp.Close()
Test with OpenSSL on Windows
:: Install OpenSSL first: winget install ShiningLight.OpenSSL
:: Full handshake diagnostics — shows TLS version, cipher, cert chain
openssl s_client -connect yourdomain.com:443 -servername yourdomain.com
:: Test specific TLS versions
openssl s_client -connect yourdomain.com:443 -tls1_2 :: Test TLS 1.2
openssl s_client -connect yourdomain.com:443 -tls1_3 :: Test TLS 1.3
:: Check if server accepts any cipher at all
openssl s_client -connect yourdomain.com:443 -cipher "ALL:COMPLEMENTOFALL"
Fix: Cipher Suite Mismatch on Windows
Windows clients negotiate ciphers using the Schannel cipher list. If the server requires a cipher that the Windows client does not support, the handshake fails with "No suitable cipher suites."
# List Windows cipher suites (PowerShell)
Get-TlsCipherSuite | Select-Object Name, Exchange, Cipher, Hash | Format-Table -AutoSize
# Enable a specific cipher suite if it's missing
Enable-TlsCipherSuite -Name "TLS_ECDHE_RSA_WITH_AES_256_GCM_SHA384"
# Disable a weak cipher suite
Disable-TlsCipherSuite -Name "TLS_RSA_WITH_3DES_EDE_CBC_SHA"
Fix: Certificate Binding Issue in IIS
If the certificate exists in the store but IIS shows a handshake error, the binding may be corrupt. Rebuild it:
# Remove and re-add the HTTPS binding
Import-Module WebAdministration
$siteName = "Default Web Site"
Remove-WebBinding -Name $siteName -Protocol https -Port 443
New-WebBinding -Name $siteName -Protocol https -Port 443 -HostHeader "yourdomain.com"
# Re-assign certificate
$thumbprint = "YOUR_CERT_THUMBPRINT"
$binding = Get-WebBinding -Name $siteName -Protocol https
$binding.AddSslCertificate($thumbprint, "My")
# If that fails, reset via netsh:
netsh http delete sslcert ipport=0.0.0.0:443
$guid = [guid]::NewGuid().ToString("B")
netsh http add sslcert ipport=0.0.0.0:443 certhash=$thumbprint appid="$guid" certstorename=MY
Diagnosing SSL Handshake Failures on macOS
macOS
Terminal
# Diagnose SSL handshake with verbose output
openssl s_client -connect yourdomain.com:443 -servername yourdomain.com -debug 2>&1 | head -50
# Test specific TLS versions (use Homebrew openssl for -tls1_3 support)
brew install openssl
/opt/homebrew/opt/openssl/bin/openssl s_client -connect yourdomain.com:443 -tls1_2
/opt/homebrew/opt/openssl/bin/openssl s_client -connect yourdomain.com:443 -tls1_3
# Check curl TLS handshake (macOS curl uses LibreSSL/Secure Transport)
curl -v https://yourdomain.com 2>&1 | grep -E "TLS|SSL|cipher|error"
# Check if a specific cipher is accepted
openssl s_client -connect yourdomain.com:443 -cipher "ECDHE-RSA-AES256-GCM-SHA384"
# For Homebrew Apache/Nginx server-side errors, check logs:
# Apache error log:
tail -f /opt/homebrew/var/log/httpd/error_log
# Nginx error log:
tail -f /opt/homebrew/var/log/nginx/error.log
macOS handles TLS at the OS level (Secure Transport on older macOS, Network.framework on macOS 11+). Certificate trust issues also appear in the Console app: open Console.app → search for "TLS" or "SSL" to see system-level TLS diagnostics.