Programming languages
PHP "cURL error 60: SSL certificate problem": how to fix
cURL error 60: SSL certificate problem: unable to get local issuer certificatecURL 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, andcurl.cainfois empty. - Fix: set
curl.cainfo(used by the curl extension) andopenssl.cafile(used by stream functions such asfile_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 -showcertslists 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-certificatespackage, or download a freshcacert.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.cainfoat 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_VERIFYHOSTset to 0. It disables the hostname check, so a valid certificate for any other domain is accepted.'verify' => falsein Guzzle or Laravel. Same effect, often left in production after a quick test.- Using a
cacert.pemfrom 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.