Self-signed certificate in certificate chain (tested)
self-signed certificate in certificate chain and unable to verify the first certificate mean your client cannot build a path from the server's certificate to a root it trusts. Usually a corporate proxy or a private CA is in the way, or the server forgot to send its intermediate. We reproduced both errors and tested the fixes for Node.js, npm, curl, Python and git.
1. Which error means what
A server certificate (the leaf) is signed by an intermediate CA, which is signed by a root CA. The client must find that root in its own trust store. The error tells you which link broke.
| Node / npm code | Message | Typical cause |
|---|---|---|
SELF_SIGNED_CERT_IN_CHAIN | self-signed certificate in certificate chain | The server sent a root your client does not trust. Classic corporate TLS-inspecting proxy or private company CA. |
UNABLE_TO_GET_ISSUER_CERT_LOCALLY | unable to get local issuer certificate | Same private CA, but the server did not send the root. Your client lacks it. |
UNABLE_TO_VERIFY_LEAF_SIGNATURE | unable to verify the first certificate | The server sent only its leaf, without the intermediate. Or your client lacks the CA. |
DEPTH_ZERO_SELF_SIGNED_CERT | self-signed certificate | The server certificate signs itself. Common in dev and on appliances. |
2. Our test setup
We made a root CA "Test Corp Root CA", an intermediate "Test Corp Intermediate CA" and a leaf for localhost and 127.0.0.1. A Node https server sent one of four chains:
- (a) full: leaf + intermediate + root
- (b) chain: leaf + intermediate (the normal, correct setup)
- (c) leaf only: intermediate missing
- (d) self-signed: one certificate that signs itself
None of the clients trusted our root at first. That is exactly the position of a laptop behind a company proxy.
openssl req -x509 -newkey rsa:2048 -nodes -keyout root.key -out root.pem \
-days 30 -subj "/CN=Test Corp Root CA" \
-addext "basicConstraints=critical,CA:TRUE" -addext "keyUsage=critical,keyCertSign,cRLSign"
# intermediate and leaf signed with: openssl x509 -req -CA ... -extfile ext.cnf
3. The exact errors, per client
| Client | (a) full chain | (b) leaf + intermediate | (c) leaf only | (d) self-signed |
|---|---|---|---|---|
Node https.get / fetch (err.cause.code) | SELF_SIGNED_CERT_IN_CHAIN | UNABLE_TO_GET_ISSUER_CERT_LOCALLY | UNABLE_TO_VERIFY_LEAF_SIGNATURE | DEPTH_ZERO_SELF_SIGNED_CERT |
| npm ping | same code as Node | same code as Node | same code as Node | same code as Node |
| curl | (60) self-signed certificate in certificate chain | (60) unable to get local issuer certificate | (60) unable to get local issuer certificate | (60) self-signed certificate |
| Python urllib / requests | CERTIFICATE_VERIFY_FAILED ... self-signed certificate in certificate chain | ... unable to get local issuer certificate | ... unable to get local issuer certificate | ... self-signed certificate |
| git (Debian, GnuTLS) | server verification failed: certificate signer not trusted. (CAfile: /etc/ssl/certs/ca-certificates.crt CRLfile: none), for all four | |||
| openssl s_client | Verify return code: 19 | 20 | 21 (unable to verify the first certificate) | 18 |
Node 24 adds a hint to some messages. For (c) and (d) the full text was:
unable to verify the first certificate; if the root CA is installed locally, try running Node.js with --use-system-ca
With fetch, the top-level error is only TypeError: fetch failed. The real reason is in err.cause.code. See TypeError: Failed to fetch.
npm was slow to fail. Each npm ping took about 70 s (we timed 1m10s) because npm retries before giving up. With --fetch-retries=0 it fails at once.
curl reports the same text for (b) and (c). Only Node and openssl tell them apart. Python requests wraps the error in SSLError(... Max retries exceeded ...), which is misleading: nothing was retried that could succeed.
4. Find out which case you have
openssl s_client -connect host:443 -servername host -showcerts </dev/null
Look at the s: (subject) and i: (issuer) lines. On our leaf-only server we saw a single certificate, 0 s:CN=localhost issued by CN=Test Corp Intermediate CA, and return code 21. If the last issuer is a name like your company or your antivirus, a proxy is intercepting TLS. Ask IT for that root CA as a PEM file.
5. Fix: trust the CA, per tool (each verified)
Every fix below turned the error into a 200 against server (a), using only root.pem. None touches the system trust store or a global config.
| Tool | Fix | Notes from our test |
|---|---|---|
| Node.js | NODE_EXTRA_CA_CERTS=/path/root.pem node app.js | Adds to the built-in roots. https://example.com still returned 200. Setting process.env.NODE_EXTRA_CA_CERTS inside the script did nothing (still UNABLE_TO_GET_ISSUER_CERT_LOCALLY); it is read at startup. A wrong path only prints "Warning: Ignoring extra certs from `/tmp/nope.pem`, load failed". |
| npm | cafile=/path/root.pem in an .npmrc | We used a throwaway --userconfig file. Result: npm notice PONG 34ms. Or run npm config set cafile /path/root.pem to save it in your own .npmrc. |
| curl | curl --cacert root.pem URL or CURL_CA_BUNDLE=root.pem | Both worked. |
| git | git -c http.sslCAInfo=/path/root.pem ... or GIT_SSL_CAINFO= | The TLS error went away (our test server is not a git repo, so git then said "not valid: is this a git repository?"). For one host only: git config http.https://host/.sslCAInfo /path/root.pem. |
| Python requests | REQUESTS_CA_BUNDLE=/path/root.pem or verify="/path/root.pem" | SSL_CERT_FILE did not help requests. And REQUESTS_CA_BUNDLE replaces the default list: with only root.pem, https://example.com failed. Use a bundle of the default CAs plus your root (cat $(python3 -m certifi) root.pem > combined.pem); that passed both. |
| Python urllib | SSL_CERT_FILE=/path/root.pem or ssl.create_default_context(cafile=...) | Both worked. On this Debian box example.com still loaded, because the default certs directory is still searched. |
6. The missing intermediate is a server bug
For server (c), trusting root.pem was not enough. Node, npm, curl, Python and git all still failed, because nothing could link the leaf to the root. Browsers often hide this by fetching or caching the intermediate, so "it works in Chrome" proves little.
Two fixes, both verified:
- Right fix (server): serve leaf + intermediate. Server (b) with
root.pemtrusted gave 200 in curl and Node. In Node https usecert: fs.readFileSync('fullchain.pem')(leaf first, then intermediate). In nginx use the fullchain file inssl_certificate. - Client workaround: trust a bundle of root + intermediate. With
bundle.pem, all six clients reached server (c).
Server (a) shows that sending the root is harmless when the client trusts it, but it is pointless. Clients ignore a root that the server sends.
7. Why not just turn verification off
All of these "work", which is the problem. They accept any certificate, including an attacker's.
$ NODE_TLS_REJECT_UNAUTHORIZED=0 node -e "fetch('https://localhost:8983/')..."
(node:1579287) Warning: Setting the NODE_TLS_REJECT_UNAUTHORIZED environment variable to '0' makes TLS connections and HTTPS requests insecure by disabling certificate verification.
NODE_TLS_REJECT_UNAUTHORIZED=0turns off checks for every connection in the process, not just the one host.strict-ssl=falsein npm: ping returned PONG with no warning at all. Packages can then be swapped in transit.requests.get(..., verify=False)printedInsecureRequestWarning: Unverified HTTPS request is being made to host 'localhost'.curl -kandgit -c http.sslVerify=falseworked silently.
The proper fix takes one line too: point the tool at the CA file.
8. Related errors
In Chrome the same problem shows as NET::ERR_CERT_AUTHORITY_INVALID. If the handshake fails before any certificate check, see ERR_SSL_PROTOCOL_ERROR. To check when a certificate expires, see check SSL certificate expiry.
Frequently asked questions
What does 'self signed certificate in certificate chain' mean?
The server sent a chain that ends in a root CA your client does not trust. In our test, sending leaf + intermediate + private root gave SELF_SIGNED_CERT_IN_CHAIN in Node and npm, and curl error 60 with the same text.
How do I fix it for npm behind a corporate proxy?
Get the proxy's root CA as a PEM file and set cafile=/path/root.pem in your .npmrc (npm config set cafile /path/root.pem). In our test npm ping went from SELF_SIGNED_CERT_IN_CHAIN to PONG. Do not use strict-ssl=false.
What causes 'unable to verify the first certificate'?
Usually the server sends its leaf certificate without the intermediate. Node reports UNABLE_TO_VERIFY_LEAF_SIGNATURE. Trusting the root alone did not fix it in any client we tried; the server must send the intermediate, or you trust root + intermediate.
Does NODE_EXTRA_CA_CERTS replace the default CAs?
No. It adds to them; https://example.com still worked with it set. It is read only at process start, so setting it in process.env inside your script has no effect.
Why does Python requests ignore SSL_CERT_FILE?
requests uses its own CA bundle setting. In our test SSL_CERT_FILE fixed urllib but not requests. Use REQUESTS_CA_BUNDLE or verify=, and include the default CAs too, since it replaces the bundle.
Written with AI assistance. Measured on 2026-10-02 with Node.js 24.21.0 (https.get and fetch), npm 11.19.0, curl 8.14.1 (OpenSSL 3.5.7), Python 3.13.5 urllib and requests 2.32.3, git 2.47.3 (Debian, GnuTLS) and openssl 3.5.7. We built a private root CA, an intermediate and a leaf with openssl, and served four chain variants from a Node https server on 127.0.0.1. PlainHeaders is our extension; Node.js, npm, curl, Python, git and openssl are free.