Laravel SSL Handshake Failed: How to Find the Cause and Fix It

Fix "SSL handshake failed" errors in Laravel's HTTP client, mailer, MySQL and Redis connections: reproduce the failure with openssl s_client, then fix CA bundles, TLS versions, ports and SNI without turning off certificate verification.

Medium severity Infrastructure Security Updated 2026-10-16 Markdown

Before any HTTPS, SMTPS or TLS database traffic flows, the client and server run a TLS handshake. The client says which protocol versions and cipher suites it supports and which hostname it wants (SNI). The server picks a version and cipher, then presents a certificate chain, which the client checks against its trusted CA bundle. If any of those steps fails, the connection is closed and PHP reports a handshake error.

When Laravel is the client, as with Http::get(), sending mail or connecting to a managed database, the handshake uses the OpenSSL library and CA bundle on the server running PHP, not your browser's. That is why a URL can open fine in Chrome and still fail from your app, a queue worker or a Docker container.

The error text usually tells you which step failed:

Error What it usually means
cURL error 35: OpenSSL SSL_connect: SSL_ERROR_SYSCALL The connection was dropped mid-handshake: a firewall or proxy, the wrong port, or a server that rejects clients without SNI
error:0A000410:SSL routines::sslv3 alert handshake failure No protocol version or cipher suite in common between client and server
error:0A00010B:SSL routines::wrong version number TLS spoken to a plaintext port, or the reverse. Common with mail ports
error:0A000152:SSL routines::unsafe legacy renegotiation disabled The remote server is too old for OpenSSL 3's defaults
cURL error 60: SSL certificate problem: unable to get local issuer certificate The handshake reached certificate checking, but the CA bundle cannot verify the chain

The tempting fix is to switch verification off with Http::withoutVerifying(), 'verify' => false or verify_peer => false. Do not ship that. It keeps the traffic encrypted but stops checking who you are talking to, so anyone who can intercept the connection can impersonate the API, mail server or database and read everything you send it, including API keys and passwords. Every cause in this guide has a fix that keeps verification on.

The Problem

An SSL handshake failure means your Laravel application and the server it is connecting to could not agree on how to set up an encrypted connection, so the connection was dropped before any data was sent. In Laravel it usually shows up as cURL error 35 from the HTTP client, a "wrong version number" or STARTTLS error from the mailer, or an SSL error from a MySQL or Redis connection.

How to Fix

  1. 1

    Reproduce the handshake outside Laravel

    Run the handshake from the same server or container that runs Laravel, so you test the same OpenSSL build and CA bundle:

    openssl s_client -connect api.example.com:443 -servername api.example.com

    Force a protocol version to see which ones the remote side accepts:

    openssl s_client -connect api.example.com:443 -servername api.example.com -tls1_2
    openssl s_client -connect api.example.com:443 -servername api.example.com -tls1_3

    Check which OpenSSL and cURL builds PHP itself is using, since the CLI and PHP-FPM can differ:

    php -r 'echo OPENSSL_VERSION_TEXT, PHP_EOL, curl_version()["ssl_version"], PHP_EOL;'

    If openssl s_client also breaks, the problem is the remote server, the network or protocol support, not your Laravel code. If it succeeds but Laravel does not, compare the hostname, port and CA bundle in your Laravel config.

  2. 2

    Update the CA bundle PHP verifies against

    If the error mentions "certificate verify failed" or "unable to get local issuer certificate", PHP cannot find a trusted CA that signed the remote certificate. Update the system bundle:

    sudo apt install --reinstall ca-certificates

    Slim Docker images often ship without it, so install it in the image itself:

    # Dockerfile
    RUN apt-get update && apt-get install -y ca-certificates

    Check whether php.ini points PHP at a different, older bundle:

    php -i | grep -E "openssl.cafile|curl.cainfo"

    If either is set, point both at the system bundle (/etc/ssl/certs/ca-certificates.crt on Debian and Ubuntu) or remove them. If openssl s_client shows "verify error:num=20" against a public API, the remote server may be missing its intermediate certificate. That is the provider's bug to fix. Report it rather than disabling verification.

    For a private CA, such as an internal service, trust that one CA for that one client:

    Http::withOptions(['verify' => storage_path('certs/internal-ca.pem')])->get($url);
  3. 3

    Fix protocol, cipher and SNI mismatches

    "sslv3 alert handshake failure" means the two sides share no protocol version or cipher suite. Check what a working client negotiates:

    curl -sv https://api.example.com -o /dev/null 2>&1 | grep -E "SSL connection|TLSv"

    OpenSSL 3, the version on Ubuntu 22.04 and later, rejects TLS 1.0, TLS 1.1 and legacy renegotiation by default. If the remote server only speaks those, the fix belongs on that server. Lowering your OpenSSL security level re-enables broken protocol versions for every connection your app makes.

    Connect by hostname, not IP address. A request to an IP sends no SNI, and servers behind Cloudflare or a shared load balancer then cannot pick a certificate and drop the handshake. The same applies to your database and Redis host settings.

    If the failure only happens on one network, look for a corporate proxy or firewall doing TLS inspection. It replaces the remote certificate with its own, which your server does not trust.

  4. 4

    Match the mail port to the right scheme

    "wrong version number" from the mailer almost always means TLS on a plaintext port or the reverse. Port 465 expects TLS from the first byte (smtps). Port 587 starts in plaintext and upgrades with STARTTLS (smtp):

    MAIL_PORT=465
    MAIL_SCHEME=smtps
    MAIL_PORT=587
    MAIL_SCHEME=smtp

    In Laravel 11 and later, an empty scheme setting makes Laravel pick smtps for port 465 and smtp for every other port, so the usual mistake is an explicit smtps scheme left over on port 587. Laravel 10 and earlier configure this through the encryption setting instead. Symfony Mailer upgrades port 587 connections with STARTTLS whenever the server offers it, so no extra setting is needed for that.

  5. 5

    Configure TLS for MySQL and Redis

    Managed databases from AWS, DigitalOcean, Aiven and others usually require TLS and sign their certificates with their own CA. Download the provider's CA file and point MySQL at it. The default config/database.php already reads this variable:

    DB_HOST=db.example.com
    MYSQL_ATTR_SSL_CA=/etc/ssl/certs/provider-ca.pem

    For managed Redis, switch to a rediss:// URL. Laravel maps the rediss scheme to TLS:

    REDIS_URL=rediss://default:your-password@redis.example.com:6380

    Use the hostname from your provider's dashboard, not a private IP, so the certificate matches.

How to Verify

Re-run the failing call from the same server, as the same user that runs PHP-FPM or your queue workers:

php artisan tinker
Http::get('https://api.example.com')->status();

For mail, send a test message from tinker and watch the log for the TLS error:

Mail::raw('TLS test', fn ($message) => $message->to('you@example.com'));

Then check that nobody "fixed" the error by disabling verification:

grep -rn "withoutVerifying\|'verify' => false\|verify_peer" app config

Prevention

Keep the base image and the ca-certificates package updated, connect to every service by hostname, and set the mail port and MAIL_SCHEME together. Keep Http::withoutVerifying() out of production code and flag it in code review. StackShield monitors your own certificate and TLS configuration, so handshake failures caused by your server show up before your users report them.

Frequently Asked Questions

What does "SSL handshake failed" mean in Laravel?

It means Laravel and the server it was connecting to could not agree on how to set up an encrypted connection, so the connection was closed before any data was sent. The cause is usually an outdated CA bundle, a protocol or cipher mismatch, a mail port configured with the wrong scheme, or a connection made by IP address without SNI.

Is it safe to use Http::withoutVerifying() to fix SSL errors?

No. It keeps the connection encrypted but stops Laravel checking the server certificate, so anyone who can intercept the traffic can impersonate the API and read the credentials you send it. Fix the CA bundle, or trust a specific private CA with the verify option, instead.

Why does the URL work in my browser but fail in Laravel?

Your browser uses its own certificate store and can fetch missing intermediate certificates on its own. Laravel uses the OpenSSL library and CA bundle on the server running PHP, which may be older, missing in a slim Docker image, or behind a proxy that inspects TLS. Test from that server with openssl s_client to see what PHP sees.

What is cURL error 35 in Laravel?

cURL error 35 is a generic SSL connect error raised by the HTTP client when the TLS handshake fails. The OpenSSL message after it, such as SSL_ERROR_SYSCALL or "sslv3 alert handshake failure", tells you whether the connection was dropped or the two sides had no protocol or cipher in common.

Free security check

Is your Laravel app exposed right now?

34% of Laravel apps we scan have at least one critical issue, and most teams do not find out until something breaks. The free scan checks your live app in 60 seconds. Then StackShield re-runs every check after each deploy, so a fix you ship today does not quietly regress next week.

18% have debug mode on
72% missing security headers
12% have exposed .env
Scan My App Free No signup for the scan. Continuous monitoring on a 14-day trial, no card.