curl says self-signed certificate in certificate chain. npm says SELF_SIGNED_CERT_IN_CHAIN. Python says certificate verify failed. Java says PKIX path building failed and never uses the words self-signed at all. All four are reporting the same condition.
The first answer you will find tells you to pass -k, set verify=False, or run npm config set strict-ssl false. That advice is wrong for every cause described below, and it is wrong in a specific way: it does not fix anything. It removes the check that was telling you something is wrong, and leaves the something in place.
There are four causes. The correct fix is completely different for each of them — in one case the fix is not even on your machine — and in every case it is roughly one line of configuration. That is the part worth knowing up front: you are not choosing between a fast hack and a slow correct fix. Both are one-liners. Only one of them leaves certificate validation working. So identify the cause first.
What the Error Actually Means
Certificate validation walks a chain. Your server presents a leaf certificate, the leaf names its issuer, that issuer names its own issuer, and the walk continues upward until it reaches a certificate that your local trust store already contains. That last certificate is a root, and roots are self-signed by definition — a root certificate is signed by its own key, because there is nothing above it to sign it.
So a self-signed certificate in the chain is not an anomaly. Every valid TLS connection ends at one. The error means the walk reached a self-signed certificate that your trust store does not contain, so validation ran out of chain without ever reaching something trusted.
That matters because it tells you what is not wrong. Nothing has expired. Nothing is malformed. The hostname matches. This is purely a question of which certificates your machine trusts, which is why the fix is always a trust-store change and never a certificate reissue.
One distinction does most of the diagnostic work, and it is worth reading before anything else:
- ✓Depth 0 — the leaf certificate itself is self-signed. There is no chain at all; the server handed you one certificate that signed itself. OpenSSL reports
Verify return code: 18, Node reportsDEPTH_ZERO_SELF_SIGNED_CERT. This is cause 2 below, a genuinely self-signed server. - ✓Depth above 0 — the leaf was signed by something, and the untrusted self-signed certificate sits higher up the chain. OpenSSL reports
Verify return code: 19, Node reportsSELF_SIGNED_CERT_IN_CHAIN. Something is issuing certificates on your network that your tools do not know about. This is almost always cause 1, a TLS-inspecting proxy.
Two adjacent codes complete the picture, and both mean the chain is broken rather than untrusted: Verify return code: 20 is unable to get local issuer certificate, and 21 is unable to verify the first certificate. Those point at cause 3 or cause 4.
Identify the Cause Before You Change Anything
Run this against the host that is failing. It prints the chain the server actually sent, with the subject and issuer of every certificate in it:
openssl s_client -connect example.com:443 -servername example.com -showcerts < /dev/null 2>/dev/null \
| grep -E "^ *[0-9]+ s:|^ *i:|Verify return code"A healthy public chain looks like this — the leaf, an intermediate, and an issuer at the top that your machine recognizes:
0 s:CN = example.com
i:C = US, O = Let's Encrypt, CN = R11
1 s:C = US, O = Let's Encrypt, CN = R11
i:C = US, O = Internet Security Research Group, CN = ISRG Root X1
Verify return code: 0 (ok)Now read the issuer on the last line of your own output. That name is the diagnosis:
- ✓It names a security vendor or your employer — Zscaler, Netskope, Palo Alto, Fortinet, Sophos, Blue Coat, or your own company — and you are on a corporate network. Cause 1.
- ✓The chain has one certificate whose subject and issuer are identical, and it belongs to a dev box, an appliance, a staging host, or something on your LAN. Cause 2.
- ✓The chain has exactly one certificate but it was issued by a real public CA, and the top issuer is not present in the output at all. The server is not sending its intermediate. Cause 3.
- ✓The chain looks completely normal and ends at a well-known root, but validation still fails — and it fails only inside a container or on a CI runner. Cause 4.
There is one more check worth running, and it settles cause 1 faster than anything else. Look at the same host from outside your network with the SSL checker. If it reports a normal public CA chain while your machine sees a different issuer, that difference is the proxy. Two observers looking at one server and seeing two different issuers is exactly what TLS interception looks like from the inside.
Cause 1: A Corporate TLS-Inspecting Proxy
This is the most common cause in any office, and the worst explained.
Your employer runs a proxy that inspects HTTPS traffic. It cannot read encrypted traffic without breaking the encryption, so it does exactly that: it terminates your TLS connection, opens its own connection to the real server, and re-signs everything with a private CA that the company controls. Every certificate you see has been reissued by that CA. The chain is real, the connection is encrypted, and the top of the chain is a self-signed root belonging to the proxy vendor.
Your browser works fine, because IT pushed that root into the operating system trust store through MDM or group policy on the day your laptop was provisioned. Your terminal does not, because curl, Node, Python, and Java do not all read the operating system trust store. That single fact explains the symptom that brings most people here: it works in Chrome, it fails in my terminal, and I have changed nothing.
You can confirm it in seconds. The issuer name in the diagnostic above will name a product or your company, and the external view from the SSL checker will disagree with what your machine sees. If both are true, stop looking for problems with the server — there is nothing wrong with it.
The fix is to trust the corporate root, in every tool that needs it. First, get the root as a PEM file. On macOS it is in the system keychain:
security find-certificate -a -c "Zscaler Root CA" -p /Library/Keychains/System.keychain > corp-root.pemOn Windows, list the machine root store, find the alias, and export it:
certutil -store Root
certutil -store Root "Zscaler Root CA" corp-root.cer
certutil -encode corp-root.cer corp-root.pemOn Linux, IT has usually already dropped it somewhere under /usr/local/share/ca-certificates/. If not, ask them for it — this is a routine request and the root is not a secret. It is a public certificate; the private key that matters never leaves the proxy.
Then install it into each tool's store using the per-tool sections below. That is the whole fix, and it is permanent: once the root is trusted, every future connection through the proxy validates normally.
What you should not do here is disable verification, and the reason is sharper than the usual one. On an inspected network, certificate validation is the only thing that distinguishes the proxy your employer sanctioned from one nobody sanctioned. Trusting the corporate root means you trust that specific CA and reject everything else. Turning verification off means you accept any certificate from anyone — including whatever is on the coffee shop network next Tuesday. You would be giving up the exact protection that made the error appear in the first place.
Cause 2: A Genuinely Self-Signed Certificate
Sometimes the certificate really is self-signed and nothing is intercepting anything. A dev server with a certificate someone generated with openssl req in 2019. A staging environment. A NAS, a printer, a router admin page, an out-of-band management controller, a Kubernetes API endpoint, a database with TLS enabled and a hand-rolled certificate.
The tell is the one from the routing section: a single certificate whose subject and issuer are the same string, reported at depth 0. There is no chain because nobody built one.
The fix is to trust that specific certificate, scoped as narrowly as the tool allows. Fetch it first:
openssl s_client -connect dev.internal:8443 -servername dev.internal < /dev/null 2>/dev/null \
| openssl x509 -outform PEM > dev-internal.pemThen point the failing tool at that one file rather than adding it to your machine-wide store. curl --cacert dev-internal.pem trusts it for one command. NODE_EXTRA_CA_CERTS trusts it for one process. Git can scope it to one repository host. Machine-wide trust is a bigger grant than the problem requires, and it is easy to forget you made it.
If you are doing this more than occasionally, the per-certificate approach is the wrong shape and you should stop repeating it. Two better options:
- ✓For local development,
mkcertcreates a local CA, installs it into your system and browser stores once, and issues certificates from it. Every local service you create afterwards just works. - ✓For an organization, run an actual internal CA — step-ca, Vault's PKI engine, or your platform's built-in equivalent. Distribute one root to every machine, issue short-lived certificates from it automatically, and the problem disappears structurally rather than one host at a time.
The difference matters at scale. Trusting fifty individual self-signed certificates is fifty things nobody will ever revoke. Trusting one internal root is one thing, and it can be rotated.
Cause 3: An Incomplete Chain Misreported as Self-Signed
This is the cause where the error message lies to you, and it sends people down the wrong path more than any other.
A server is supposed to send its leaf certificate and every intermediate above it, stopping just short of the root. If it sends only the leaf, your client has a certificate signed by an issuer it has never heard of and no way to bridge the gap to a root it trusts. Different clients describe that situation differently. Some say unable to get local issuer certificate, which is accurate. Others — older OpenSSL builds and some Node versions in particular — report it as self-signed or as UNABLE_TO_VERIFY_LEAF_SIGNATURE.
Nothing in this scenario is self-signed. The certificate came from a real public CA and is perfectly valid. The server is just misconfigured.
Two tells identify it. The first is counting what the server sent:
openssl s_client -connect example.com:443 -servername example.com -showcerts < /dev/null 2>/dev/null \
| grep -c "BEGIN CERTIFICATE"Almost every certificate from a public CA needs at least two: the leaf and one intermediate. If that command prints 1, you have found the problem.
The second tell is the one people report without realizing it is diagnostic: it works in the browser and fails in curl. Browsers implement AIA fetching — when an intermediate is missing, they read the issuer URL out of the leaf certificate and download it themselves. curl, OpenSSL, Python, and Node do not. So a server missing its intermediate looks fine to every human visitor and breaks every script, which is exactly why it stayed broken long enough for you to hit it.
The fix is on the server, not on your machine. Whoever runs it needs to serve the full chain — with certbot that means using fullchain.pem rather than cert.pem, which is the single most common way this happens:
ssl_certificate /etc/letsencrypt/live/example.com/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/example.com/privkey.pem;Then reload the server and confirm the chain is complete, either by re-running the count above or by checking it from outside with the SSL checker, which reports the full chain as presented.
Resist fixing this one on the client. You can absolutely download the missing intermediate and add it to your own trust store, and it will work, and you will have hidden a server misconfiguration that is still breaking every other client that talks to it.
Cause 4: A Stale or Missing CA Bundle
The last cause is the environment rather than the network: the machine simply does not have a current set of roots. Slim and Alpine base images ship without ca-certificates installed. Distroless images may have no trust store at all. An old base image carries roots from whenever it was built.
The tell is environmental rather than textual — the same URL works on your host and fails inside the container, at the same moment, with no server change in between.
apt-get update && apt-get install -y --no-install-recommends ca-certificates
apk add --no-cache ca-certificates && update-ca-certificatesThis is the same root-cause family as the stale bundle section in expired certificate errors in curl, Python, git, Node, and Java, which covers the per-runtime details and the cached-Docker-layer trap that makes the fix appear not to work. Everything there applies unchanged.
curl: SSL certificate problem: self-signed certificate in certificate chain
Two wordings are in circulation, because OpenSSL 3.0 rewrote its verification strings. Both are the same condition, and people paste both:
curl: (60) SSL certificate problem: self-signed certificate in certificate chain
curl: (60) SSL certificate problem: self signed certificate in certificate chainExit code 60 is curl's generic peer-certificate-cannot-be-authenticated code, shared with expiry and hostname failures, so the text after the colon is the part that identifies the cause.
For a single command against one host, point curl at the one root you want to trust:
curl --cacert corp-root.pem https://example.comFor everything in the shell, set the bundle once:
export CURL_CA_BUNDLE=/etc/ssl/certs/ca-certificates.crtFor the whole machine on Linux, install the root into the system store, which fixes curl and most other OpenSSL-backed tools at the same time:
sudo cp corp-root.pem /usr/local/share/ca-certificates/corp-root.crt
sudo update-ca-certificatesOn macOS and Windows, curl is often built against the operating system trust store instead of a file, in which case importing the root into the system store is the fix and CURL_CA_BUNDLE will appear to do nothing. Check which backend you have with curl -V.
Node.js and npm: SELF_SIGNED_CERT_IN_CHAIN
From an application:
Error: self-signed certificate in certificate chain
at TLSSocket.onConnectSecure (node:_tls_wrap:1674:34)
code: 'SELF_SIGNED_CERT_IN_CHAIN'From npm, which is where most people meet it:
npm ERR! code SELF_SIGNED_CERT_IN_CHAIN
npm ERR! request to https://registry.npmjs.org/express failed,
reason: self-signed certificate in certificate chainNode's three codes map directly onto the causes above, which makes it the fastest runtime to diagnose from the error alone:
- ✓
SELF_SIGNED_CERT_IN_CHAIN— an untrusted self-signed certificate above the leaf. Cause 1 or cause 4. - ✓
DEPTH_ZERO_SELF_SIGNED_CERT— the leaf itself is self-signed. Cause 2. - ✓
UNABLE_TO_VERIFY_LEAF_SIGNATURE— the chain is incomplete. Cause 3, and the fix is on the server.
Node compiles its root store into the binary and does not read the operating system trust store on any platform. That is the direct explanation for cause 1's signature symptom: IT installed the corporate root into Windows or macOS, every browser picked it up, and Node never looked. Installing the root into the system store again will not help.
The correct lever is NODE_EXTRA_CA_CERTS, which appends your root to the built-in set rather than replacing it — so you keep every public CA and add one more:
export NODE_EXTRA_CA_CERTS=/etc/ssl/certs/corp-root.pemFor npm specifically, configure the same file so it survives across shells:
npm config set cafile /etc/ssl/certs/corp-root.pemNote that cafile replaces npm's bundle rather than appending to it, so the file you point at must contain the public roots too if you talk to anything other than the internal registry. Concatenating your corporate root onto a copy of the system bundle is the usual answer.
Python: CERTIFICATE_VERIFY_FAILED self signed certificate in certificate chain
Python surfaces the OpenSSL string unchanged, which means the unhyphenated older wording is what most people have pasted into a search box:
requests.exceptions.SSLError: HTTPSConnectionPool(host='example.com', port=443):
Max retries exceeded with url: / (Caused by SSLError(SSLCertVerificationError(1,
'[SSL: CERTIFICATE_VERIFY_FAILED] certificate verify failed: self signed
certificate in certificate chain (_ssl.c:1006)')))On OpenSSL 3.0 and newer the same failure reads self-signed certificate in certificate chain. The _ssl.c line number is a location in CPython's source and means nothing here.
Python has two trust stores and they drift apart. requests uses the certifi bundle inside your virtualenv; the standard library ssl module uses whatever paths OpenSSL was compiled with. Check both:
python3 -c "import ssl, certifi; print(certifi.where()); print(ssl.get_default_verify_paths())"The environment variables are the right fix, because they apply to both and survive package upgrades:
export REQUESTS_CA_BUNDLE=/etc/ssl/certs/ca-certificates.crt
export SSL_CERT_FILE=/etc/ssl/certs/ca-certificates.crtFor pip on its own, configure the bundle rather than passing it every time:
pip config set global.cert /etc/ssl/certs/corp-root.pemYou will also find advice to append your root directly to certifi's cacert.pem. It works, and the next pip install --upgrade certifi silently overwrites the file and the error comes back with no obvious connection to what you did. Use the environment variable.
git: SSL certificate problem
git reports this in whichever dialect its TLS backend speaks. Builds against OpenSSL pass curl's message through:
fatal: unable to access 'https://git.example.com/repo.git/': SSL certificate
problem: self-signed certificate in certificate chainBuilds against GnuTLS, which is common on Debian and Ubuntu, say only that verification failed and never say why:
fatal: unable to access 'https://git.example.com/repo.git/': server certificate
verification failed. CAfile: /etc/ssl/certs/ca-certificates.crt CRLfile: noneThe GnuTLS wording cannot be diagnosed from the message at all — run the openssl s_client check from the diagnosis section against the git host and read the issuer yourself.
Point git at the bundle containing your root:
git config --global http.sslCAInfo /etc/ssl/certs/corp-root.pemgit also supports scoping the setting to one host, which is the better choice when only an internal server needs the extra root. The URL goes in the middle of the config key:
git config --global http.https://git.example.com/.sslCAInfo /etc/ssl/certs/corp-root.pemThat form leaves validation against public CAs completely untouched everywhere else, which a global setting does not.
Java: PKIX path building failed
Java is the reason many people never find a page like this one: it does not use the words self-signed anywhere in the error.
javax.net.ssl.SSLHandshakeException: PKIX path building failed:
sun.security.provider.certpath.SunCertPathBuilderException: unable to find
valid certification path to requested targetPath building failed means Java could not assemble a chain from the leaf up to something in its truststore — which is precisely what an untrusted self-signed root at the top produces. It is the same condition curl calls self-signed certificate in certificate chain. Note that path validation failed is a different error with a different cause, usually expiry.
To see which certificates Java is actually being offered:
java -Djavax.net.debug=ssl:handshake -jar app.jarJava's truststore ships inside the JDK, not with the operating system, so a corporate root installed by IT is invisible to it for the same reason it is invisible to Node. You can import the root into the JDK's own store:
keytool -importcert -trustcacerts -alias corp-root -file corp-root.pem \
-keystore "$JAVA_HOME/lib/security/cacerts" -storepass changeit -nopromptThat works, and it is erased the next time the JDK is upgraded or the container image is rebuilt. The more durable approach is a separate truststore that you own, seeded from the JDK's and kept alongside your application:
keytool -importcert -trustcacerts -alias corp-root -file corp-root.pem \
-keystore corp-truststore.jks -storepass changeit -noprompt
java -Djavax.net.ssl.trustStore=corp-truststore.jks \
-Djavax.net.ssl.trustStorePassword=changeit -jar app.jarOn Windows the JDK truststore lives under the installation directory, and older JDK 8 layouts put it under jre\lib\security\cacerts instead:
keytool -list -v -keystore "C:\Program Files\Java\jdk-21\lib\security\cacerts" -storepass changeitDocker and CI: Where This Actually Bites
Most reports of this error come from a build rather than a laptop, for two reasons that stack.
The build runs on a corporate network, so every package download goes through the inspecting proxy — but the container has none of the trust configuration your host picked up from IT. And the base image is slim, so it may not have a usable trust store to begin with. Cause 1 and cause 4 arrive together, which is why the same Dockerfile builds at home and fails at the office.
The correct pattern is to install the root during the build and let every tool in the image pick it up from the system store:
FROM node:22-slim
COPY corp-root.crt /usr/local/share/ca-certificates/corp-root.crt
RUN apt-get update \
&& apt-get install -y --no-install-recommends ca-certificates \
&& update-ca-certificates \
&& rm -rf /var/lib/apt/lists/*
ENV NODE_EXTRA_CA_CERTS=/etc/ssl/certs/ca-certificates.crtThe ENV line is there because Node ignores the system store, as covered above. Every runtime you install needs the same treatment — a Python layer wants REQUESTS_CA_BUNDLE, a Java layer wants its truststore.
Two things that catch people:
- ✓Multi-stage builds. Trust installed in a builder stage does not travel to the final image. Whatever runs at runtime needs the root in its own stage.
- ✓The corporate root is not secret, but it does identify your employer. It is a public certificate and safe to hand around internally. Committing it to a public repository is not a security incident, but it does tell the internet which security vendor you run and what your internal domains are called. Keep it in the private repo or inject it at build time.
Do not bake the bypass into the image instead. A Dockerfile with npm config set strict-ssl false in it produces an image where certificate validation is off for every process, forever, on every network it ever runs on — including production, where there is no proxy and no reason for it.
The One Section on Disabling Verification
Every tool has a switch that makes the error disappear immediately:
curl -k https://example.com
npm config set strict-ssl false
export NODE_TLS_REJECT_UNAUTHORIZED=0
requests.get(url, verify=False)
pip install --trusted-host pypi.org --trusted-host files.pythonhosted.org package
git config --global http.sslVerify falseJava's version takes a custom TrustManager whose checkServerTrusted method has an empty body — twenty lines that look like real engineering and switch off the entire certificate system.
These are debugging steps. They are never fixes. They do not install anything, trust anything, or repair anything. They tell your tool to stop checking, after which an untrusted certificate, a forged certificate, and a correctly issued one all look identical to your code.
The argument against them is unusually strong for this particular error, and it is not the usual lecture about best practice. Look at what the two options actually cost. The bypass is one line. The correct fix — pointing the tool at the root you decided to trust — is also one line, and you already know which line, because it is in the section for your tool above. These are two one-liners of roughly equal effort, and only one of them leaves validation working. There is no tradeoff being made here. There is only a worse option that happens to be more famous.
Three consequences worth being specific about:
- ✓The scope is far wider than the problem.
NODE_TLS_REJECT_UNAUTHORIZED=0and a globalhttp.sslVerify falseapply to every host the process or user ever contacts. One untrusted root on an internal dev box switches off validation for your package registry, your secrets backend, and your production API. - ✓On an inspected network it removes the only thing that was working. Trusting the corporate root means accepting one specific CA and rejecting everything else — which still catches an attacker. Disabling verification means accepting anything from anyone, which is a strictly worse position than the one you started in.
- ✓They persist and they spread. These lines end up in
.npmrc,pip.conf, Dockerfiles, and CI configuration, get committed, get inherited by images built years later, and are never reviewed again. Nothing will ever surface a certificate problem in that stack afterwards, because you removed the thing that surfaces them.
There is one legitimate use: running curl -k once, interactively, to confirm that the failure really is certificate trust and not something else on the connection. That is a diagnosis, it takes one command, and it ends when you have your answer. If a bypass outlives the debugging session, it is no longer a bypass — it is your security posture.
Getting It Right Once
Every version of this error resolves the same way: work out which of the four causes you have, then put the right root in the store your tool actually reads. The four causes look identical in the error message and share almost nothing in their fixes, so the diagnosis is the entire job — once you know whether you are looking at a proxy, a self-signed dev box, a server missing its intermediate, or an empty trust store, the fix is a line of configuration and it stays fixed.
The one that is not your problem to fix is cause 3. If the server is not sending its intermediate, patching your own machine hides a misconfiguration that is breaking every other client too. Tell whoever owns it.
And to be straightforward about it: this is not a problem monitoring solves. Self-signed and untrusted-root errors are configuration, they appear the moment something changes, and they announce themselves loudly. Nothing needs to watch for them. ExpiryPing watches the thing that fails silently instead — the expiry date on a certificate that is working perfectly today and stops on a specific date nobody has written down. If you are already in the trust store sorting this out, it is a reasonable moment to check what else is quietly counting down. Free for up to 3 domains.