Fixing SELF_SIGNED_CERT_IN_CHAIN on Internal Registries
Corporate networks often intercept TLS traffic with a proxy that re-signs certificates using the company's own certificate authority, and internal registries are frequently served with certificates from a private CA. Node.js ships with its own bundle of public root certificates and does not trust either, so installs fail with SELF_SIGNED_CERT_IN_CHAIN or UNABLE_TO_GET_ISSUER_CERT_LOCALLY. The tempting fix — strict-ssl=false — disables certificate verification for every package you install, which is exactly the protection that stops a tampered download. This guide shows how to make npm, pnpm, Yarn and Node.js trust your CA properly, on developer machines, in CI and in Docker.
Exact symptoms and error messages
npm error code SELF_SIGNED_CERT_IN_CHAIN
npm error errno SELF_SIGNED_CERT_IN_CHAIN
npm error request to https://registry.internal.example.com/@acme%2fui failed, reason: self-signed certificate in certificate chain
npm error code UNABLE_TO_GET_ISSUER_CERT_LOCALLY
npm error request to https://registry.npmjs.org/react failed, reason: unable to get local issuer certificate
pnpm and Yarn report the same underlying Node.js errors:
ERR_PNPM_META_FETCH_FAIL GET https://registry.internal.example.com/@acme%2Fui: request to https://registry.internal.example.com/@acme%2Fui failed, reason: self-signed certificate in certificate chain
➤ YN0001: │ RequestError: self-signed certificate in certificate chain
If the failure happens for public registry URLs as well, a TLS-intercepting proxy is in the path. If it happens only for the internal registry, its certificate comes from a private CA.
Root cause analysis
A TLS connection is trusted when the server's certificate chain ends at a root certificate the client trusts. Operating systems and browsers trust your company CA because IT installed it in the system store. Node.js, by default, does not use the system store; it uses the Mozilla root bundle compiled into the binary. So the same URL works in a browser and fails in npm. The registry access model is covered in Private Registries and Access Control.
Resolution: trust the CA, do not disable verification
1. Get the CA certificate in PEM format. Ask IT for the root (and any intermediate) certificate, or export it from the system store. Save it as a PEM file, for example ~/certs/acme-root-ca.pem. It should start with -----BEGIN CERTIFICATE-----.
2. Make Node.js trust it, for every tool:
export NODE_EXTRA_CA_CERTS="$HOME/certs/acme-root-ca.pem"
npm install
NODE_EXTRA_CA_CERTS adds the certificates to Node's bundled roots at startup, so it covers npm, pnpm, Yarn and any Node.js script that makes HTTPS requests. Put it in your shell profile, and in CI job environments.
3. Or configure npm and pnpm specifically:
# user ~/.npmrc (the path is machine-specific, so not in the project file)
cafile=/home/dev/certs/acme-root-ca.pem
cafile makes npm (and pnpm) trust only the certificates in that file for registry requests, replacing the default roots. If the file contains only your company CA, requests to the public registry through a non-intercepting network will then fail. Put the company CA and the public roots in the file, or prefer NODE_EXTRA_CA_CERTS, which adds rather than replaces.
4. Or use the system store (newer Node.js). Recent Node.js releases can read the operating system's trust store with the --use-system-ca flag (Node.js 23.8 and 22.15 onwards; platform coverage has grown across releases, so check your version's documentation):
export NODE_OPTIONS="--use-system-ca"
That reuses the CA your IT team already deployed, with no certificate files to manage.
5. Yarn Berry reads its own settings: set caFilePath in .yarnrc.yml, or rely on NODE_EXTRA_CA_CERTS.
Diagnosing the certificate chain
Before changing configuration, confirm which certificate is presented and who issued it. openssl shows the chain the server sends:
openssl s_client -connect registry.internal.example.com:443 -servername registry.internal.example.com -showcerts </dev/null 2>/dev/null | grep -E "^ *[0-9] s:|^ *i:"
The output lists each certificate's subject (s:) and issuer (i:). If the last issuer is your company's CA, you need that CA's root certificate. If the chain is incomplete — the server sends only its own certificate without the intermediate — the fix belongs on the server: configure it to send the full chain, because some clients cannot build the path themselves.
You can also test Node.js directly, independent of npm:
node -e "require('https').get('https://registry.internal.example.com/-/ping', r => console.log(r.statusCode)).on('error', e => console.error(e.code))"
NODE_EXTRA_CA_CERTS=~/certs/acme-root-ca.pem node -e "require('https').get('https://registry.internal.example.com/-/ping', r => console.log(r.statusCode)).on('error', e => console.error(e.code))"
If the first command prints SELF_SIGNED_CERT_IN_CHAIN and the second prints 200, the PEM file is the right one.
Proxies, environment variables and other tools
Corporate networks often combine TLS interception with an HTTP proxy that must be configured explicitly. npm and pnpm read proxy and https-proxy from .npmrc and the HTTPS_PROXY environment variable; Node.js's built-in fetch historically ignored proxy variables unless configured, and newer releases support them through NODE_USE_ENV_PROXY. When installs work but a postinstall script or a CLI that downloads binaries fails, it is usually because that tool makes its own HTTPS requests and needs both the proxy settings and the CA — which NODE_EXTRA_CA_CERTS provides, and a per-tool cafile setting does not. Tools that shell out to curl or git use the operating system's store, so they may work while Node-based tools fail, which is another reason to keep the Node.js and OS trust configurations aligned.
Why not strict-ssl=false?
strict-ssl=false # do not do this
With verification off, any machine between you and the registry can serve modified packages, and npm will install them. On an intercepting corporate network that is the proxy itself — but the same setting follows the laptop onto café Wi-Fi, into CI runners on shared networks, and into Docker images. The integrity hashes in the lockfile provide some protection for already-locked packages, but new resolutions, metadata and anything without a lockfile entry are exposed. Every alternative above achieves the same result — installs work — while keeping verification on. The broader supply-chain picture is in Supply-Chain Security Hardening.
CI and Docker
In CI, add the certificate to the runner image or write it from a secret at job start:
- name: Trust corporate CA
run: |
echo "$CORP_ROOT_CA" > "$RUNNER_TEMP/corp-ca.pem"
echo "NODE_EXTRA_CA_CERTS=$RUNNER_TEMP/corp-ca.pem" >> "$GITHUB_ENV"
env:
CORP_ROOT_CA: ${{ secrets.CORP_ROOT_CA_PEM }}
- run: pnpm install --frozen-lockfile
In Docker, install the CA into the image's system store and point Node.js at it:
FROM node:22-slim
COPY certs/acme-root-ca.pem /usr/local/share/ca-certificates/acme-root-ca.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=/usr/local/share/ca-certificates/acme-root-ca.crt
A CA certificate is public information, so committing the PEM file to the repository is acceptable; the private key never leaves the CA. Keep the file name and location stable across images and repositories, so documentation and scripts can refer to one path, and rotate it in one place when IT renews the root certificate.
Worked example: an install that worked in the browser
A new engineer on a corporate laptop can open https://registry.internal.example.com in the browser but pnpm install fails with SELF_SIGNED_CERT_IN_CHAIN. A colleague suggests strict-ssl=false, which works. A week later, a security review finds the setting in dozens of personal .npmrc files and in two Dockerfiles. The platform team publishes the corporate root CA in the internal documentation, adds NODE_EXTRA_CA_CERTS to the standard developer setup script and the base CI and Docker images, and adds a CI check that fails if any committed .npmrc contains strict-ssl=false. Installs work everywhere with verification on.
Prevention and guardrails
- Distribute the corporate CA through the standard developer setup, CI images and Docker base images.
- Prefer
NODE_EXTRA_CA_CERTSor--use-system-caovercafile. - Ban
strict-ssl=falsein committed configuration with a CI check. - Test from CI and a clean container, not only a configured laptop.
Frequently Asked Questions
Does NODE_EXTRA_CA_CERTS work if set inside .npmrc? No. It is read by Node.js at process start, before npm reads any configuration. Set it in the environment.
Why does it fail for registry.npmjs.org too? A TLS-intercepting proxy re-signs all traffic, including public registries. Trust the proxy's CA; do not add a bypass for public hosts.
Is ca= in .npmrc the same as cafile=?
ca holds certificate contents inline, cafile a path. Both replace the default roots for npm's requests; NODE_EXTRA_CA_CERTS adds to them.
Should the CA file include intermediate certificates? Include the root certificate at minimum. Add intermediates only if the server does not send them in its chain; the better fix in that case is to configure the server to send the full chain.
What about NODE_TLS_REJECT_UNAUTHORIZED=0?
It disables certificate verification for every HTTPS request in the process — installs, application code, SDKs — and Node.js prints a warning when it is set. It has the same problems as strict-ssl=false, more broadly. Never set it outside a throwaway debugging session.
Related
- Private Registries and Access Control covers private registry setup and access.
- Fixing npm 401 Unauthorized on a Private Registry addresses the next error after TLS works.
- Setting Up Verdaccio as a Private Proxy Registry is a common internal registry that needs a trusted certificate.
- Configuring a Project .npmrc for Consistent Installs explains which settings belong in committed configuration.