Programming languages

PHP "cURL error 60: SSL certificate problem": how to fix

By · Last verified

Error messagecURL error 60: SSL certificate problem: unable to get local issuer certificate

cURL error 60: SSL certificate problem means PHP's curl extension reached the server but could not verify its certificate chain. On Windows and hand-built PHP the usual reason is that PHP has no CA bundle configured; on Linux servers it is more often a missing intermediate certificate on the remote server or a private root that PHP does not know.

Quick fix

Setting curl.cainfo to a current CA bundle fixes most cases.

Check which CA file PHP is using, from the same environment that fails (web or CLI):

php --ini
php -i | grep -iE 'curl.cainfo|openssl.cafile|openssl.capath'
php -r 'print_r(openssl_get_cert_locations());'

If curl.cainfo and openssl.cafile are empty and the default file does not exist, set them in the php.ini that PHP loads:

curl.cainfo = "/etc/ssl/certs/ca-certificates.crt"
openssl.cafile = "/etc/ssl/certs/ca-certificates.crt"

On Windows use the path of a cacert.pem downloaded from curl.se. Then restart PHP-FPM, Apache or IIS. If the bundle is already correct, test the remote server's chain with the SSL checker.

Causes

1. PHP has no CA bundle configured

Windows builds, XAMPP, WampServer and many custom builds ship libcurl without a usable CA path.

  • Confirm: openssl_get_cert_locations() points to a file that does not exist, and curl.cainfo is empty.
  • Fix: set curl.cainfo (used by the curl extension) and openssl.cafile (used by stream functions such as file_get_contents('https://...')) to a valid bundle.

2. The remote server sends an incomplete chain

If PHP fails for one site but works for others, the remote site may be missing its intermediate certificate.

  • Confirm: openssl s_client -connect api.example.com:443 -servername api.example.com -showcerts lists only one certificate.
  • Fix: the server owner must install the full chain. See missing intermediate certificate and the chain builder.

3. The CA bundle is outdated

An old cacert.pem or an unpatched OS does not contain newer roots, and fails for sites that chain to them.

  • Fix: update the OS ca-certificates package, or download a fresh cacert.pem.

4. A corporate proxy or internal CA

TLS-inspecting proxies and internal APIs use roots that are not in Mozilla's list.

  • Fix: append the PEM root to a copy of the bundle and point curl.cainfo at the copy, or pass it per request:
$ch = curl_init('https://intranet.example.com/api');
curl_setopt($ch, CURLOPT_CAINFO, '/etc/ssl/certs/company-bundle.pem');
curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
$body = curl_exec($ch);

5. The wrong php.ini was edited

CLI and FPM commonly use separate files (for example /etc/php/8.3/cli/php.ini and /etc/php/8.3/fpm/php.ini on Debian and Ubuntu).

  • Fix: read "Loaded Configuration File" from phpinfo() on the web side and edit that file.

Fixes by environment

Every fix gives PHP a current CA bundle through curl.cainfo or openssl.cafile, plus any private roots.

Debian / Ubuntu:

sudo apt-get install --reinstall ca-certificates
sudo systemctl restart php8.3-fpm   # match your PHP version

RHEL family: the bundle is /etc/pki/tls/certs/ca-bundle.crt; add private roots to /etc/pki/ca-trust/source/anchors/ and run update-ca-trust.

Windows, XAMPP, WampServer: download cacert.pem, set both directives in the php.ini used by Apache, and restart Apache from the control panel.

Docker: make sure the image has ca-certificates installed, and copy private roots in before running update-ca-certificates.

Frameworks: Guzzle and Laravel accept 'verify' => '/path/to/bundle.pem'; Symfony HttpClient accepts the cafile option. Composer has its own settings; see Composer SSL operation failed.

WordPress: core HTTP requests use the CA bundle shipped in wp-includes/certificates/. Keeping WordPress updated keeps that bundle current; plugins that call curl directly use PHP's settings.

Don't do this

  • curl_setopt($ch, CURLOPT_SSL_VERIFYPEER, false). This is the most copied "fix" for error 60 and disables chain verification, so any network attacker can impersonate the API.
  • CURLOPT_SSL_VERIFYHOST set to 0. It disables the hostname check, so a valid certificate for any other domain is accepted.
  • 'verify' => false in Guzzle or Laravel. Same effect, often left in production after a quick test.
  • Using a cacert.pem from a random repository. Use the file published by the curl project or your OS package.

FAQ

I edited php.ini but still get error 60. Why?

You probably edited the wrong file. php --ini shows the file used by the CLI; PHP-FPM and Apache mod_php usually load a different one. Check phpinfo() from a web page for "Loaded Configuration File", edit that file, and restart PHP-FPM or Apache.

Where do I get cacert.pem for Windows?

Download it from the curl project's CA extract page (curl.se/docs/caextract.html). It is Mozilla's root list converted to PEM. Save it in a stable path such as your PHP folder, point curl.cainfo and openssl.cafile at it, and refresh it periodically.

The curl command works on the same server, but PHP fails. How?

The curl binary and PHP's curl extension can be built against different TLS libraries or with different default CA paths, and PHP may be running in a chroot or container. Compare curl -v (the CAfile: line) with php -r "print_r(openssl_get_cert_locations());".

How do I set the CA file in Guzzle or Laravel?

Pass a path to the verify option: new GuzzleHttp\Client(['verify' => '/etc/ssl/certs/ca-certificates.crt']), or Http::withOptions(['verify' => '/path/to/cacert.pem']) in Laravel. Keep the value a path or true; false disables verification.

Sources

  1. PHP manual: curl_setopt (CURLOPT_CAINFO, CURLOPT_SSL_VERIFYPEER)
  2. PHP manual: cURL runtime configuration (curl.cainfo)
  3. PHP manual: OpenSSL runtime configuration (openssl.cafile)
  4. curl: CA certificates extracted from Mozilla
  5. libcurl error codes