curl error 60: the message text fingerprints the TLS backend, not your certificate
Short answer: curl: (60) means one thing only: the peer’s certificate did not verify. Everything after the colon is written by whichever TLS library curl was compiled against, so Peer certificate cannot be authenticated with known CA certificates, SSL certificate problem: self-signed certificate in certificate chain and SSL certificate verification failed: certificate signer not trusted can all be the same broken trust store on three different boxes. Run curl -V before you search for the message, because the message identifies the build and not the fault.
TL;DR
- Fix: read
curl -Vfirst. The backend name in that line decides how every TLS failure will be phrased, and searching the phrase without it sends you to answers written for a different library. - Detail: error 60 is
CURLE_PEER_FAILED_VERIFICATIONincurl.h, and the old nameCURLE_SSL_CACERTis now a#defineonto it. The two wordings you find online are the same number in different eras of the same file. - Trap: the number is not stable either. wolfSSL returns
CURLE_SSL_CACERT_BADFILE, error 77, for a missing signer, where OpenSSL returns 60. - Trap: refreshing a CA bundle removes roots as well as adding them. Between the March 2018 and August 2026 Mozilla extracts published by curl, 80 roots left and 68 arrived, and only 53 of the original 133 survived.
- Trap: if the application sets
CURLOPT_SSL_VERIFYPEERto 0, none of this appears in its logs, because the CA certificates are never loaded at all.
What curl error 60 actually means
Error 60 is a single enum value with a single meaning, and it carries no detail. In include/curl/curl.h it reads:
| |
Further down the same header there is a compatibility line that explains half the confusion in every search result you will ever read on this:
| |
CURLE_SSL_CACERT used to be its own code. It is now an alias. So when one Stack Overflow answer talks about CURLE_SSL_CACERT and another talks about CURLE_PEER_FAILED_VERIFICATION, they are discussing the same integer, seen from different decades. Nothing about the actual chain is encoded in it.
The same failure, six different sentences
One untrusted root produces a different sentence in every backend, because each backend writes its own failf() string. These are lifted from lib/vtls/ in curl’s tree:
| Backend | What curl prints | Exit code |
|---|---|---|
| OpenSSL 3.x | SSL certificate problem: self-signed certificate in certificate chain | 60 |
| OpenSSL 1.1.1 | SSL certificate problem: self signed certificate in certificate chain | 60 |
| GnuTLS | SSL certificate verification failed: certificate signer not trusted. (CAfile: ... CRLfile: ...) | 60 |
| mbedTLS | peer certificate could not be verified | 60 |
| wolfSSL | CA signer not available for verification | 77 |
| Schannel | schannel: SNI or certificate check failed: ... | 60 |
| NSS, curl 7.61.1 and older | Peer certificate cannot be authenticated with known CA certificates | 60 |
Three things in that table deserve a second look.
The wolfSSL row is not a typo. In lib/vtls/wolfssl.c, the branch for ASN_NO_SIGNER_E does this:
| |
CURLE_SSL_CACERT_BADFILE is 77, documented in the header as “could not load CACERT file, missing or wrong format”. On a wolfSSL build, a trust store that loads perfectly but lacks the right root reports itself as a problem with the file. If your monitoring alerts on curl exit codes, that single branch is enough to make a fleet look like it has two unrelated incidents.
The GnuTLS row is the only one that tells you where it looked. It prints CAfile and CRLfile in the message itself. On OpenSSL you have to ask for that separately with -v, and most people never do, which is how you end up with an hour of arguing about a certificate when the actual question was which file curl opened.
The NSS row is the odd one out for a different reason, covered below: that sentence is not written by a backend at all.
Where the OpenSSL wording comes from, and why it changed
curl does not write the OpenSSL message. It writes a prefix and then asks OpenSSL to fill in the rest. From lib/vtls/openssl.c:
| |
That %s comes straight out of OpenSSL’s crypto/x509/x509_txt.c, which is a plain switch over the X509_V_ERR_* codes. Which means the tail of the message follows OpenSSL’s source tree, not curl’s. In the 1.1.1 branch the entry reads:
| |
In current OpenSSL the same case returns "self-signed certificate in certificate chain". One hyphen. The X509_V_ERR_* code is identical, the condition is identical, the string is different. Search engines treat those as two different phrases, so quoting the message verbatim silently filters your results by OpenSSL major version, and the filter is invisible because nothing in either sentence mentions a version.
The neighbouring entry in that file is the one worth memorising, because it is what openssl verify gives you when a chain cannot be completed:
| |
Where the NSS wording came from, and why you will not see it again
Peer certificate cannot be authenticated with known CA certificates is not an NSS string at all, and it is not written by any backend. It is curl’s generic fallback text, and you get it precisely because the NSS backend declined to describe the failure.
Look at what the backend does in the 7.19.7 tree, in lib/nss.c:
| |
No failf(). It picks an error code and jumps. The label it jumps to hides the one useful fact behind the verbose flag:
| |
infof only prints under -v. So with no error buffer set, the curl tool falls back to curl_easy_strerror(), which in lib/strerror.c of that era said:
| |
That sentence is therefore a description of an enum constant, printed by a tool that was never told what went wrong. The actual reason was sitting one -v away as a bare negative integer.
Those integers come from NSS’s lib/util/SECerrs.h, where every entry is an offset from SEC_ERROR_BASE, defined as -0x2000 in secerr.h. The one you usually want is
| |
which arrives on the wire as NSS error -8179. The one that derails people is SEC_ERROR_UNRECOGNIZED_OID at SEC_ERROR_BASE + 143, or -8049, phrased "Unrecognized Object Identifier." It reads like a parsing bug and usually means an old NSS met an algorithm identifier that postdates it. Neither sentence resembles the curl wording they end up wrapped in, which is the entire problem.
The wording has a hard version boundary. CURLE_SSL_CACERT stopped being a distinct code in curl 7.62.0, released 31 October 2018, and the header still says so in a comment above the alias:
| |
The strerror.c case went with it. Current curl has only CURLE_PEER_FAILED_VERIFICATION: "SSL peer certificate or SSH remote key was not OK". So a box printing the old sentence is running curl 7.61.1 or older, full stop, and that is a much more useful thing to learn from an error message than anything it says about certificates. curl 7.19.7, the build that shipped in enterprise Linux images of that generation, was released on 4 November 2009 and carries 72 published vulnerabilities in curl’s own release table.
NSS itself is gone from curl too. The deprecation list records it under past removals:
- NSS (removed in 8.3.0)
and the 8.3.0 changelog, dated 13 September 2023, carries the one-liner nss: remove support for this TLS library. Two lines further down the same list sits OpenSSL 1.1.1 and older (removed in 8.18.0), which means the unhyphenated self signed wording has already stopped being reachable on any curl built after January 2026. Give it a few years and that phrase becomes a version fingerprint exactly the way the NSS one is now.
Why openssl s_client said the certificate was fine
Because openssl s_client and curl were not reading the same store. That is the whole trick, and it is documented rather than folklore. curl’s SSL library comparison puts it in the glossary:
For engines that use a database and do not also support files, the
CURLOPT_CAINFOoption is ignored.
A curl linked against a database-backed library looks in that database. openssl s_client looks at OPENSSLDIR or whatever you pass to -CAfile. Populate one and not the other and you get exactly the standoff I walked into: Verify return code: 0 (ok) from s_client, error 60 from curl, same host, same second, same machine.
Even on a modern box with both tools on OpenSSL, the defaults differ in shape. On the machine I am writing this on:
$ curl -V
curl 8.14.1 (x86_64-pc-linux-gnu) libcurl/8.14.1 OpenSSL/3.5.7 ...
$ curl -sSv https://untrusted-root.badssl.com/ -o /dev/null 2>&1 | grep -i ca
* CAfile: /etc/ssl/certs/ca-certificates.crt
* CApath: /etc/ssl/certs
One is a single concatenated file of 224,449 bytes, the other a directory holding 301 hash-named symlinks. Both are generated from the same package, and they do drift apart the moment somebody edits one of them by hand.
The reproduction, for what it is worth, is two lines:
$ curl -sS https://untrusted-root.badssl.com/ -o /dev/null
curl: (60) SSL certificate problem: self-signed certificate in certificate chain
$ echo | openssl s_client -connect untrusted-root.badssl.com:443 \
-servername untrusted-root.badssl.com 2>/dev/null | grep Verify
Verify return code: 19 (self-signed certificate in certificate chain)
Note what each tool chose to show. OpenSSL gives you the X.509 reason code, 19. curl gives you 60, which is the same for every reason there is, and pushes the actual reason into prose. If you only ever copy one habit out of this article, make it running s_client alongside curl so you get the number.
The first command is curl -V
curl -V answers the question the error message is pretending to answer. It names the backend, the version, and therefore the entire vocabulary of failures you are about to see:
curl 7.19.7 (x86_64-...) libcurl/7.19.7 NSS/3.27.1 zlib/1.2.3
Two seconds of reading and the rest of the incident is bounded. That line says the box will phrase trust failures in curl’s own pre-7.62.0 vocabulary, that it consults an NSS database rather than a PEM file for its roots, and that CURLOPT_CAINFO may therefore be ignored outright. It also sets an upper bound on the protocol side of the conversation, which matters when a partner turns off older TLS: for the OpenSSL half of the world, TLS 1.3 did not exist before OpenSSL 1.1.1 on 11 September 2018, so any libcurl/x OpenSSL/1.0.x line is a box that cannot negotiate it no matter what you put in the trust store.
I did not run curl -V first. I spent a while comparing the certificate chain the browser showed against the one s_client printed, found them identical, and concluded the server was fine. Which it was. The wasted time came from treating the error text as a description of the situation rather than as a property of the binary printing it.
How stale is a stale trust store: 2018 against 2026
Stale is not a feeling, so I measured it. curl publishes dated Mozilla extracts at curl.se/docs/caextract.html, which makes this a two-command experiment anyone can repeat:
| |
Counting is not enough, because a root can be replaced by a re-issued root under the same common name. So compare SHA-256 fingerprints:
| |
The two bundles I pulled while writing this carry these headers:
## Certificate data from Mozilla as of: Wed Mar 7 04:12:06 2018 GMT
## Certificate data from Mozilla as of: Thu Aug 13 03:12:01 2026 GMT
And the result:
| Measure | March 2018 extract | August 2026 extract |
|---|---|---|
| Roots in the file | 133 | 121 |
| File size | 215,556 bytes | 188,900 bytes |
| Fingerprints present in both | 53 | 53 |
| Fingerprints unique to this file | 80 | 68 |
Eight and a half years, and the headline count moved by twelve. Underneath, 148 of the 201 distinct roots changed sides. DST Root CA X3 is in the 2018 file and not the 2026 one. ISRG Root X2, GTS Root R1, GTS Root R3, GTS Root R4, GlobalSign Root CA - R6 and Sectigo Public Server Authentication Root R46 are in the 2026 file and not the 2018 one. If you have ever diffed a trust store by line count and concluded nothing much happened, that table is why the method does not work.
Cross-signing is why a frozen bundle keeps working
Here is the part that makes a stale trust store so hard to spot: it keeps working almost everywhere. I pointed the 2018 bundle at a spread of large sites in 2026 and every one of them verified:
| |
Seven hosts, seven zeroes. A bundle whose root data predates the pandemic still validates the mainstream web, because CAs cross-sign their new roots against their old ones for exactly this reason. The new hierarchy ships a chain that terminates at something your ancient store already trusts.
That is also why the failure mode looks arbitrary when it finally arrives. It does not arrive when the roots change. It arrives when one particular operator retires one particular cross-signature, and then a single endpoint out of many starts refusing while everything else on the box is fine. Take eleven outbound integrations on a host and two of them failing: that is not a network problem and it does not look like a trust problem either. It looks like those two partners broke something.
My first theory was in fact the opposite of the truth. I assumed the two failing endpoints had moved to a new root hierarchy that my store did not know, and that the cross-signed path was the thing missing. The test for that is openssl verify with the intermediates supplied by hand. The manual is precise about the flag:
-untrusted filename | uriA file or URI of untrusted certificates to use for chain building.
| |
That returned OK, which killed the theory. The control run, the same command with the cross-certificate left out, gave:
error 20 at 1 depth lookup: unable to get local issuer certificate
Error 20 is X509_V_ERR_UNABLE_TO_GET_ISSUER_CERT_LOCALLY, the string quoted earlier from x509_txt.c. Two runs, a positive and a negative, and a theory I had spent half an hour on was gone. Do the negative control. Without it, verify returning OK proves nothing except that you handed it enough certificates.
Updating a bundle removes roots too
update-ca-trust and apt install ca-certificates are not additive operations, and the 80-roots-removed column above is the size of the risk. Replacing a trust store on a machine that talks to eleven partners can fix two endpoints and break a third, and the third failure will surface hours later when some cron job runs.
Before swapping anything, test the candidate store side by side against the old one:
| |
You want the new column to be a superset of the successes in the old column. A host that went from 0 to 60 is a regression, and finding it in a loop that takes a minute is better than finding it in a support ticket.
Build endpoints.txt from whatever the application actually dials, not from what the documentation says it dials. Grep the config, grep the code, grep an hour of egress logs. On the box in front of me the config listed fewer hosts than the code did, which is the normal state of affairs: somebody hardcoded a status endpoint years ago and nobody wrote it down.
The root that no public bundle will ever ship
Refreshing ca-certificates fixes the roots Mozilla ships, and does nothing at all for the roots it does not. Those are more common than the conversation around trust stores suggests:
- an internal corporate CA, for services that never had a public certificate
- a partner’s private PKI, where the integration contract specifies their root
- a TLS-inspecting proxy sitting between you and the internet, minting certificates from its own CA
- a national or sector CA that is outside the browser root programs by design
None of these will ever arrive through a package update, no matter how many times you run the update. If one of your failing endpoints is signed by such a root, the bundle refresh will not touch it, and you will conclude the refresh did not work. It worked. It was aimed at a different layer.
This is worth separating in your head before you start, because the two layers have different fixes and different blast radii. Refreshing the public set is a bulk operation with regression risk. Adding one private anchor is surgical and affects exactly the chains that terminate at it.
Where to put an anchor so the legacy path sees it
Put the PEM in the anchors directory and run the extract, then check that the file your application actually reads is a symlink. The update-ca-trust(8) manual gives the first half:
QUICK HELP 1: To add a certificate in the simple PEM or DER file formats to the list of CAs trusted on the system: add it as a new file to directory
/etc/pki/ca-trust/source/anchors/runupdate-ca-trust extract
| |
The second half is the trap, and the same manual spells it out under “Legacy Files”:
The following file paths were used in legacy versions of the utility and have since been replaced. Scripts and configurations referencing these old paths should be updated.
/etc/pki/tls/certs/ca-bundle.crtThis file has been replaced by/etc/pki/ca-trust/extracted/pem/tls-ca-bundle.pem.
The mechanism that keeps old software working is described a few paragraphs earlier: “the classic filenames have been changed to symbolic links”. On a healthy system /etc/pki/tls/certs/ca-bundle.crt points into /etc/pki/ca-trust/extracted/, so the extract updates it for free. On a system where someone once fixed a certificate problem by writing a bundle straight to that path, it is a regular file, the symlink is gone, and update-ca-trust extract now writes to a location nothing reads.
| |
One l at the start of that output or the absence of it decides whether your last twenty minutes accomplished anything. Check it before the extract and after.
The same manual carries the other half of the warning, about the extracted directory:
Please never manually edit the files stored in this directory, because your changes will be lost and the files automatically overwritten, each time the
update-ca-trust extractcommand gets executed.
Which is, of course, exactly how the symlink got replaced by a regular file in the first place. Somebody hit a trust error, wrote the bundle where the error said to write it, and moved on.
The application logged none of this
The reason nobody noticed for so long is that the application had certificate verification turned off, so it never produced the error in the first place. The two options were sitting in the HTTP client’s setup:
| |
The documentation for CURLOPT_SSL_VERIFYPEER states what that does to the diagnostics, not just to the security:
When this option is disabled (set to zero), the CA certificates are not loaded and the peer certificate verification is skipped.
The store is not consulted. There is nothing to fail, so nothing is logged, so the trust store can rot for years without producing a single line anywhere. Every failure that got reported came from somewhere else: operational curl calls typed by hand, a cron script that used the system defaults, one integration whose client had been written by someone who left verification on. That is why a couple of endpoints looked broken and the rest looked healthy. The rest were not healthy. They were unchecked.
Sitting right next to those two lines was a third:
| |
behind an if ($this->useLocalCerts) flag that was false. The directory it pointed at did not exist. It had not existed for years and nothing had ever complained, which the docs also explain, in CURLOPT_CAINFO:
If
CURLOPT_SSL_VERIFYPEERis zero and you avoid verifying the server’s certificate,CURLOPT_CAINFOneed not even indicate an accessible file.
A dead path guarded by a false flag feeding an option that is ignored anyway. Three layers of nothing, all of it looking like configuration. I read that block twice before working out it could not possibly matter.
The order the fix has to happen in
Fix the store first, turn verification on second, and never the other way round. The sequence matters because each step has to be observable before the next one lands:
- Run
curl -Von the affected host and write down the backend. Everything after this depends on it. - Build the endpoint list from config, code and egress logs. Not from documentation.
- Run the whole list against the current store and record exit codes. This is your baseline and you will need it.
- Fetch a candidate store and run the whole list against it with
--cacert. Compare to the baseline. Nothing regresses, or you stop. - Install the candidate store. Verify that the legacy path is still a symlink into the extracted directory.
- Add any private anchors to
/etc/pki/ca-trust/source/anchors/and runupdate-ca-trust extract. Re-run the list. - Only now flip
CURLOPT_SSL_VERIFYPEERandCURLOPT_SSL_VERIFYHOSTback to true, in a deployment you can roll back.
Doing step 7 before step 5 turns a background problem into an outage. Every integration that was quietly unverified starts verifying against a store that is still wrong, and instead of two failing endpoints you have all of them. The temptation is real, because step 7 is the one that actually fixes the security hole and it is a one-line change. Resist it for the length of one deploy cycle.
Keep the model from earlier: eleven outbound endpoints, two of them failing. Steps 1 through 6 take that to eleven verified and none failing, against a store you have diffed against the one it replaced. On the host that prompted this article the store work was done inside a single sitting and needed no service restart, because nothing in that path caches the bundle across requests. Step 7 shipped separately, the following day, with the baseline from step 3 as the rollback criterion.
How I verified it, endpoint by endpoint
Verification is the loop from earlier, run once more after the change, with the baseline pinned next to it. There is no clever tooling involved and that is the point: a shell loop over a text file of hostnames, exit codes in two columns, and a diff.
What makes it trustworthy is the negative control. Run the same loop with --cacert /dev/null and confirm every line reports 77. If they do not, something in the path is ignoring your --cacert, which on a database-backed backend is the documented behaviour rather than a bug, and you have just learned that your verification loop was proving nothing.
| |
That is the third distinct exit code in this article for what a casual reading would call “certificate problem”: 60 for a chain that will not verify, 77 for a store that will not load, and 77 again on wolfSSL for a chain that will not verify. The numbers are not a taxonomy. They are an artefact of which branch in which backend happened to run.
Bottom line
Treat the text of a TLS error as a build identifier. curl -V first, the message second, and the trust store third, because the message is written by the library named in the first command and tells you nothing about the certificate that a chain dump would not tell you better. When you do get to the store, diff it by fingerprint rather than by line count, keep a baseline of exit codes across every endpoint you actually call, and remember that a refresh takes roots away as readily as it adds them: 80 out of 133 between two published Mozilla extracts, eight years apart.
<< Previous Post
|
Next Post >>