A PHP cURL call fails with a certificate error and a colleague sets CURLOPT_SSL_VERIFYPEER to false; what does that break, and what is the right fix?
answer
- trust chain versus hostname
- VERIFYPEER default true, VERIFYHOST default 2
- value 1 becomes 2 with a notice
- curl.cainfo and openssl.cafile
- CURLOPT_CAINFO for a private CA
basics
~20 sDisabling CURLOPT_SSL_VERIFYPEER accepts any certificate, so anyone on the path can impersonate the API and read credentials. Fix the trust store instead: install or point to a current CA bundle (curl.cainfo or CURLOPT_CAINFO) and keep VERIFYPEER true and VERIFYHOST 2.
solid answer
~50 s`CURLOPT_SSL_VERIFYPEER` checks that the server certificate chains to a trusted CA; `CURLOPT_SSL_VERIFYHOST` checks that the certificate names the host you asked for. The defaults are `true` and `2`. Turning the first off means **any** certificate is accepted, so an attacker on the network can present their own, decrypt the traffic and harvest the API key. The connection is still encrypted, but you no longer know who is at the other end. The usual cause of the error is a missing or outdated CA bundle on the server, or an upstream signed by a private CA. The fix is to give libcurl the right roots: set `curl.cainfo` (or `openssl.cafile`, which PHP prefers) in php.ini, or `CURLOPT_CAINFO` or `CURLOPT_CAINFO_BLOB` per handle for a private CA. `CURLOPT_SSL_VERIFYHOST => 1` is not a softer mode; PHP raises a notice and uses 2.
code
php · 16 lines<?php
declare(strict_types=1);
// Upstream signed by an internal CA: trust that CA, keep both checks on.
$ch = curl_init('https://rates.internal.example.test/v1/quote');
curl_setopt_array($ch, [
CURLOPT_RETURNTRANSFER => true,
CURLOPT_TIMEOUT => 5,
CURLOPT_CAINFO => '/etc/ssl/internal/rates-ca.pem',
CURLOPT_SSL_VERIFYPEER => true, // the default, stated for reviewers
CURLOPT_SSL_VERIFYHOST => 2, // the default; 1 is coerced to 2 with a notice
]);
if (curl_exec($ch) === false) {
error_log('TLS or transfer error: ' . curl_error($ch));
}go deeper
Know the two options and their safe defaults, true and 2, and that turning them off lets anyone impersonate the server.
Explain what each option checks, why 1 is not a valid VERIFYHOST value, and how curl.cainfo, openssl.cafile and CURLOPT_CAINFO supply the trust store.
Diagnose the real cause from curl_error and the verify result, fix the CA bundle or trust the private CA per client, and add a CI check that rejects disabled verification.
Own the platform answer: a maintained CA bundle in the base image, one HTTP helper that sets trust policy, and pinning only where the rotation cost is acceptable.
## What the two options verify TLS gives you **encryption** and **authentication**. Encryption without authentication is not safe: you might be talking privately to an impostor. PHP's cURL extension exposes libcurl's two authentication checks: | Option | Checks | Default | Safe value | |---|---|---|---| | `CURLOPT_SSL_VERIFYPEER` | the certificate chain leads to a CA in the trust store, and is valid | `true` (since cURL 7.10) | `true` | | `CURLOPT_SSL_VERIFYHOST` | the certificate's Subject Alternative Name or Common Name matches the requested host | `2` | `2` | They guard different attacks. Without peer verification, any self-made certificate passes. Without host verification, a valid certificate issued for **another** domain passes. You need both. ## What `VERIFYPEER => false` actually breaks The tempting fix for `SSL certificate problem: unable to get local issuer certificate` is to switch verification off. After that: - a machine on the network path (a hostile Wi-Fi network, a compromised router or proxy, poisoned DNS) can terminate TLS with its own certificate; - the request, including an `Authorization` header or API key, is readable and modifiable by that machine; - responses can be forged, so a shipping-rates call could return prices the attacker chose; - nothing in the logs shows it, because the transfer succeeds. The same applies to `CURLOPT_SSL_VERIFYHOST => 0`. The code often ships to production because it was added "temporarily" in development, so many teams grep for it in CI. ## The value 1 trap Old tutorials show `CURLOPT_SSL_VERIFYHOST => 1`, believing it means "check that a name exists". The manual says `1` should not be used, and libcurl dropped support for it in 7.28.1. PHP's cURL extension handles it explicitly: setting `1` raises an `E_NOTICE` saying the value is no longer accepted, and the handle uses `2`. So `1` is not a looser mode; it is a notice plus full checking. Setting `true` is the same as `1` after integer conversion, with the same outcome. ## The right fixes, by cause 1. **The server has no CA bundle or an outdated one.** Common in minimal containers and old hosts. Install the operating system's CA package, or download a maintained bundle and point PHP at it with `curl.cainfo` in php.ini. When a handle is created, PHP first uses `openssl.cafile` if set, otherwise `curl.cainfo`, and passes it as `CURLOPT_CAINFO`. 2. **The upstream uses a private or internal CA.** Trust that CA explicitly for that client: `CURLOPT_CAINFO` with a file path, `CURLOPT_CAPATH` with a directory, or `CURLOPT_CAINFO_BLOB` (PHP 8.2+) with the PEM string itself, for example from a secret store. 3. **The upstream's chain is broken** (a missing intermediate). This is their bug; ask them to fix it rather than weakening your client. 4. **Hostname mismatch.** You are calling an IP or an internal alias the certificate does not list. Call the name on the certificate, or resolve that name to the right address, and keep `VERIFYHOST` at `2`. Two practical notes on the fixes. First, a CA bundle is data that ages: roots are added and retired, so a bundle baked into an image years ago will start failing against newly issued certificates. Keeping the base image's CA package updated is part of routine patching. Second, trusting a private CA per client is narrower than adding it to the system store, because only that client then accepts certificates signed by it. Both approaches keep peer and host verification on. For high-value endpoints, `CURLOPT_PINNEDPUBLICKEY` pins the expected public key on top of normal verification. It is a trade-off, because a key rotation will break the pin. ## Diagnosing without disabling - `curl_error($ch)` carries libcurl's message, which usually names the exact problem: an unknown issuer, an expired certificate or a name mismatch. - `curl_getinfo($ch, CURLINFO_SSL_VERIFYRESULT)` returns the TLS library's verification result code. - `CURLOPT_VERBOSE` with `CURLOPT_STDERR` pointing at a stream logs the handshake, including the CA file in use. ## Review checklist - No `CURLOPT_SSL_VERIFYPEER => false` or `CURLOPT_SSL_VERIFYHOST => 0` outside tests against local fixtures. - No `CURLOPT_SSL_VERIFYHOST => 1`, which only produces a notice. - CA configuration lives in php.ini or in one shared HTTP helper, not scattered across call sites. - A certificate error is treated as an incident signal, never silenced.
- Is traffic still encrypted when CURLOPT_SSL_VERIFYPEER is false?Yes, the TLS session is still encrypted, but you no longer know whom it is encrypted to. An interceptor can complete the handshake with its own certificate, decrypt and re-encrypt everything, and the call succeeds. Encryption without authentication gives no protection against an active attacker on the path.
- What does PHP do if code sets CURLOPT_SSL_VERIFYHOST to 1?PHP's cURL extension raises an `E_NOTICE` that the value 1 is no longer accepted and sets 2 instead, so the hostname is fully checked. libcurl removed support for 1 in 7.28.1. Code that relied on 1 as a looser check gets full checking plus a notice.
- Where does PHP get its default CA file for new cURL handles?When a handle is created, PHP reads `openssl.cafile` and, if that is empty, `curl.cainfo`, and passes the path as `CURLOPT_CAINFO`. If neither is set, libcurl uses the CA bundle it was built with. Per-handle `CURLOPT_CAINFO`, `CURLOPT_CAPATH` or `CURLOPT_CAINFO_BLOB` override it.
saying these in an interview costs you the question
- Turning off VERIFYPEER is fine because the data is still encrypted.
- CURLOPT_SSL_VERIFYHOST set to 1 is a lighter check that still verifies something.
- A certificate error always means the remote server is misconfigured.
- Disabling verification in development is harmless because it never reaches production.
- VERIFYPEER alone is enough; the hostname check is redundant.