The Error
Your Node.js app hits an HTTPS endpoint and dies with:
Error: UNABLE_TO_VERIFY_LEAF_SIGNATURE
at TLSSocket.onConnectEnd (_tls_wrap.js:1495:19)
at Object.onceWrapper (events.js:422:26)
at TLSSocket.emit (events.js:327:22)
It's 2 AM. The endpoint loads fine in Chrome, curl on your laptop doesn't complain, but Node.js flat-out refuses to connect. The certificate is valid โ you can see it with your own eyes. So what gives?
What This Error Actually Means
This is not an expired cert. It's not self-signed either. The problem is that Node.js can't build a trusted chain from the server's certificate up to a root CA it recognizes.
Nine times out of ten, the cause is simple: the server sends its leaf certificate during the TLS handshake but omits the intermediate CA certificate(s). No intermediate, no chain. No chain, no connection.
Browsers handle this gracefully โ they fetch missing intermediates automatically using Authority Information Access (AIA) and cache them locally. Node.js doesn't. It needs the full chain handed to it up front, every single time.
Reproduce and Diagnose
Step 1 โ Check whether the server has a broken chain
Run OpenSSL directly against the server:
openssl s_client -connect yourdomain.com:443 -showcerts
A healthy chain shows at least two certificates:
Certificate chain
0 s:CN=yourdomain.com
i:CN=Some Intermediate CA
1 s:CN=Some Intermediate CA
i:CN=Root CA
A broken chain shows only one:
Certificate chain
0 s:CN=yourdomain.com
i:CN=Some Intermediate CA
Just the leaf. The intermediate is gone. That's your culprit.
Step 2 โ Confirm Node.js is failing
node -e "require('https').get('https://yourdomain.com', r => console.log(r.statusCode)).on('error', e => console.error(e.message))"
See UNABLE_TO_VERIFY_LEAF_SIGNATURE in the output? Confirmed.
Solutions
Solution 1 โ Fix the server (the right fix)
This is a server misconfiguration, not a Node.js bug. Fix it at the source: configure the server to send the full certificate chain.
For Nginx, bundle your certs into a single file and point ssl_certificate at it:
# Concatenate leaf + intermediate
cat your_domain.crt intermediate.crt > fullchain.pem
# In nginx.conf
ssl_certificate /etc/nginx/ssl/fullchain.pem;
ssl_certificate_key /etc/nginx/ssl/your_domain.key;
For Apache:
SSLCertificateFile /etc/ssl/certs/your_domain.crt
SSLCertificateChainFile /etc/ssl/certs/intermediate.crt
For a Node.js HTTPS server:
const https = require('https');
const fs = require('fs');
https.createServer({
key: fs.readFileSync('server.key'),
cert: fs.readFileSync('server.crt'),
ca: fs.readFileSync('intermediate.crt') // add this line
}, app).listen(443);
Reload the server, then re-run openssl s_client. You should now see both the leaf and the intermediate in the chain output.
Solution 2 โ Supply the CA cert from the Node.js client
Don't control the server? Maybe you're calling a third-party API with a broken chain. In that case, tell Node.js exactly which CA cert to trust:
const https = require('https');
const fs = require('fs');
const agent = new https.Agent({
ca: fs.readFileSync('/path/to/intermediate-or-root-ca.crt')
});
https.get({
hostname: 'yourdomain.com',
path: '/',
agent: agent
}, (res) => {
console.log(res.statusCode);
});
Using axios? Same idea, different option name:
const axios = require('axios');
const https = require('https');
const fs = require('fs');
const agent = new https.Agent({
ca: fs.readFileSync('/path/to/ca-bundle.crt')
});
const response = await axios.get('https://yourdomain.com/api', {
httpsAgent: agent
});
This is a workaround, not a permanent fix. Push the server team to resolve the chain properly.
Solution 3 โ Refresh the system CA bundle (Linux servers)
Fresh Linux boxes sometimes ship with an outdated CA bundle that's missing the issuing root. A quick update usually sorts it:
# Debian/Ubuntu
sudo apt-get update && sudo apt-get install -y ca-certificates
sudo update-ca-certificates
# RHEL/CentOS
sudo yum update ca-certificates
update-ca-trust
Node.js reads the system CA bundle at startup. No code changes needed after the update โ just restart your process.
What NOT to do
This suggestion is all over Stack Overflow. Resist it:
// DO NOT DO THIS IN PRODUCTION
process.env.NODE_TLS_REJECT_UNAUTHORIZED = '0';
// Same problem:
const agent = new https.Agent({ rejectUnauthorized: false });
Turning off certificate verification is not a fix. It disables TLS validation entirely, leaving your app wide open to man-in-the-middle attacks. Fine in a local throwaway script. A genuine security incident in production.
Verify the Fix
Three commands to confirm everything is working:
# 1. Check the server sends the full chain
openssl s_client -connect yourdomain.com:443 -showcerts 2>&1 | grep -E '(subject|issuer|Certificate chain)'
# 2. Verify the chain validates against the system CA store
openssl verify -CAfile /etc/ssl/certs/ca-certificates.crt /dev/null console.log('OK:', r.statusCode)).on('error', e => console.error('FAIL:', e.message))"
You should see OK: 200. Or whatever status code the endpoint normally returns โ the point is no more error.
For a deeper audit, run the server through SSL Labs. It will flag missing intermediates explicitly and give you a chain visualization worth bookmarking.
Lessons Learned
- Browsers lie to you. Chrome and Firefox silently fetch missing intermediates and cache them. You can load a page just fine while Node.js,
curl, and every server-to-server client reject the same endpoint. Always test TLS withopenssl s_clientโ not a browser tab. - Certificate renewals are the #1 trigger. Someone renewed the leaf cert, copied it into place, and forgot to re-bundle the intermediate. Add the
openssl s_clientchain check to your deployment runbook. It takes five seconds and catches this every time. - Client-side workarounds are a smell. If you're injecting
caoverrides in every service that talks to an endpoint, that's technical debt accumulating. The chain belongs on the server. File the ticket. - Container base images go stale. A Node.js app in a Docker image built from
node:16a year ago may not trust newer root certificates. AddRUN apt-get install -y ca-certificatesto your Dockerfile and rebuild โ don't chase this bug in production at 2 AM.

