Node.js TLS Error

Error: unable to verify the first certificate

error code: UNABLE_TO_VERIFY_LEAF_SIGNATURE  ·  also: Error: certificate chain incomplete  ·  self signed certificate in certificate chain

Complete reference — root cause (incomplete chain from server), correct fix with NODE_EXTRA_CA_CERTS and https.Agent ca:, axios / node-fetch / undici specifics, corporate proxy and MITM scenarios, openssl s_client diagnostics, and why rejectUnauthorized: false must never be used in production.

Quick Answer: The server sent its certificate without the intermediate CA certificates needed to reach a trusted root. Node.js rejects it because it cannot build the trust chain. Fix with NODE_EXTRA_CA_CERTS=/path/to/ca-bundle.pem (process-wide, zero code changes) or an https.Agent({ ca: fs.readFileSync('ca-bundle.pem') }) passed to your HTTP client. Never set rejectUnauthorized: false or NODE_TLS_REJECT_UNAUTHORIZED=0 in production — that disables all TLS security.

What is UNABLE_TO_VERIFY_LEAF_SIGNATURE?

UNABLE_TO_VERIFY_LEAF_SIGNATURE is an OpenSSL error code surfaced by Node.js's TLS implementation when it receives a server certificate (the "leaf" certificate) but cannot verify it against any trusted Certificate Authority because the intermediate CA certificates required to build the chain are absent.

Despite being one of the most common TLS errors Node.js developers encounter — particularly when hitting internal services, corporate proxies, or recently configured servers — it is not documented in the official Node.js error reference (see nodejs/node#33705 and nodejs/node#52582). This page fills that gap.

The error surfaces in the Node.js tls module as an Error object with code: 'UNABLE_TO_VERIFY_LEAF_SIGNATURE'. It has nothing to do with your Node.js code being wrong — it is a server-side certificate chain configuration problem (or a corporate proxy inserting its own certificate).

Exact error strings you will see in the terminal:
Error: unable to verify the first certificate
Error: certificate chain incomplete
FetchError: request to https://... failed, reason: unable to verify the first certificate
AxiosError: unable to verify the first certificate
RequestError: unable to verify the first certificate

Full Error Example

Error: unable to verify the first certificate
    at TLSSocket.onConnectEnd (_tls_wrap.js:1509:19)
    at Object.onceWrapper (events.js:422:26)
    at TLSSocket.emit (events.js:327:22)
    at TLSSocket._finishInit (_tls_wrap.js:944:8)
    at TLSWrap.ssl.onhandshakedone (_tls_wrap.js:718:12) {
  code: 'UNABLE_TO_VERIFY_LEAF_SIGNATURE'
}

Using the built-in fetch (Node.js 18+) or node-fetch:

TypeError: fetch failed
    at Object.fetch (node:internal/deps/undici/undici:11372:11)
    at process.processTicksAndQueues (node:internal/process/task_queues:95:5) {
  [cause]: Error: unable to verify the first certificate
      at TLSSocket.onConnectEnd (node:_tls_wrap:1564:19) {
    code: 'UNABLE_TO_VERIFY_LEAF_SIGNATURE'
  }
}

With axios:

AxiosError: unable to verify the first certificate
    at RedirectableRequest._currentRequest.emit (.../node_modules/follow-redirects/index.js:657:22)
    at TLSSocket.socketErrorListener (node:_http_client:481:9) {
  code: 'ERR_TLS_CERT_ALTNAME_INVALID',       // sometimes seen alongside
  errno: -72,
  config: { ... },
  request:  ClientRequest { ... }
}

Diagnostic Fields

PropertyValueMeaning
err.code'UNABLE_TO_VERIFY_LEAF_SIGNATURE'OpenSSL error: leaf certificate cannot be verified (no valid chain to a trusted root)
err.message'unable to verify the first certificate'Human-readable OpenSSL reason string
err.library'SSL routines'OpenSSL subsystem that raised the error
err.reason'unable to verify the first certificate'Same as message; set on older OpenSSL builds
err.syscall'connect'Failed during TLS handshake, not data transfer

All Causes at a Glance

CauseSymptom / ContextFix
Server sends leaf certificate only (no intermediates) All environments; most common cause. openssl s_client shows only one certificate in the chain. NODE_EXTRA_CA_CERTS pointing to the intermediate CA, or fix server config to include the full chain.
Corporate HTTPS inspection proxy Works outside corporate network; fails behind VPN or office network. The proxy re-signs traffic with its own root CA. Obtain the corporate root CA certificate from IT; set NODE_EXTRA_CA_CERTS to it.
MITM / SSL-inspection firewall (Zscaler, Palo Alto, Cisco Umbrella, etc.) Intercepts all HTTPS; certificate CN shows the proxy, not the real server. Common in CI/CD runners on managed corporate infrastructure. Export the proxy root CA from the OS store; set NODE_EXTRA_CA_CERTS or ca: in agent.
Load balancer / CDN strips intermediate certificates Works from a browser (which caches intermediates) but not from a fresh Node.js process. Common with misconfigured nginx, HAProxy, or Cloudflare custom origins. Fix the server/load-balancer TLS config to include the full chain. Short-term: supply intermediate via NODE_EXTRA_CA_CERTS.
Self-signed certificate on internal service Internal API or dev/staging endpoint signed with a company-internal CA not in Node.js's built-in root store. Set NODE_EXTRA_CA_CERTS to the internal CA certificate. Never use rejectUnauthorized: false in shared/staging environments.
Node.js version with outdated root CA bundle Error on Node.js 12–14 but not on 18+. The server uses a newer root CA that older Node.js versions do not include. Upgrade Node.js. Short-term: manually add the newer root CA via NODE_EXTRA_CA_CERTS.
Docker container with no system CA bundle Works locally; fails in a minimal Docker image (e.g., node:alpine without ca-certificates installed). Add RUN apk add --no-cache ca-certificates to the Dockerfile, or copy your CA bundle and set NODE_EXTRA_CA_CERTS.

Cause 1 – Incomplete Certificate Chain (Server-Side)

A TLS certificate chain works like a chain of trust: root CA → intermediate CA → leaf certificate. Browsers and operating systems cache intermediate CA certificates from previous visits; Node.js does not. Node.js requires the server to send the complete chain in the TLS handshake. If the server omits the intermediate CA certificate, Node.js has no path from the leaf to a trusted root and throws UNABLE_TO_VERIFY_LEAF_SIGNATURE.

This is the most common cause and is a server misconfiguration, not a Node.js bug. The fix has two parts: a server-side fix (the permanent solution) and a client-side workaround if you do not control the server.

Diagnose with openssl s_client

# Show all certificates the server sends during the TLS handshake
openssl s_client -connect api.example.com:443 -showcerts

# For a server on a non-standard port or requiring SNI:
openssl s_client -connect internal-api.corp.com:8443 -servername internal-api.corp.com -showcerts

# Quick check — if the verify return code is not 0, the chain is broken:
echo | openssl s_client -connect api.example.com:443 2>/dev/null | grep "Verify return code"
# Good output: Verify return code: 0 (ok)
# Bad output:  Verify return code: 21 (unable to verify the first certificate)

If the output shows only one BEGIN CERTIFICATE / END CERTIFICATE block before the server certificate details, the server is sending only the leaf certificate. You need the intermediate CA certificate — it is the certificate that signed the leaf but is not itself the root CA.

Extract the intermediate CA certificate

# Run with -showcerts and save ALL certificates to a file
openssl s_client -connect api.example.com:443 -showcerts 2>/dev/null < /dev/null | \
  awk '/-----BEGIN CERTIFICATE-----/,/-----END CERTIFICATE-----/' > all-certs.pem

# The file now contains the leaf + any intermediates in order.
# The LAST certificate block is typically the highest intermediate (closest to root).
# Keep only the intermediate CA block(s) — not the first block (which is the leaf).

# Verify what you extracted:
openssl x509 -in intermediate-ca.pem -text -noout | grep -E "Subject:|Issuer:|CA:"

Cause 2 – Corporate HTTPS Proxy / MITM Inspection

Enterprise networks commonly route HTTPS traffic through an inspection proxy (Zscaler, Palo Alto Prisma, Cisco Umbrella, Blue Coat, etc.). The proxy terminates your TLS connection, inspects the traffic, and re-encrypts it with a certificate signed by the corporate root CA. Operating systems and browsers have this corporate root CA pre-installed; Node.js does not.

This is the most common reason the error appears in CI/CD pipelines (GitHub Actions, Jenkins, GitLab CI on a corporate runner) but not in local development outside the network, or vice versa.

Identifying a proxy: If openssl s_client -connect api.github.com:443 -showcerts shows a certificate whose CN is something like Zscaler Root CA or your company's name rather than github.com, you are behind an MITM inspection proxy. Ask your IT/security team for the root CA certificate in PEM format.

Export the corporate CA from the OS store

# Linux (Debian/Ubuntu) — copy the full system bundle
cp /etc/ssl/certs/ca-certificates.crt ./system-ca.pem

# Linux (RHEL/CentOS/Fedora)
cp /etc/pki/tls/certs/ca-bundle.crt ./system-ca.pem

# macOS — export all system root CAs
security find-certificate -a -p /System/Library/Keychains/SystemRootCertificates.keychain \
  > system-ca.pem
# Also include user-added CAs (corporate installs go here):
security find-certificate -a -p ~/Library/Keychains/login.keychain >> system-ca.pem

# Windows (PowerShell) — export corporate CAs from the Windows certificate store
Get-ChildItem Cert:\LocalMachine\Root | ForEach-Object {
  [System.IO.File]::AppendAllText("C:\certs\corporate-ca.pem",
    "-----BEGIN CERTIFICATE-----`n" +
    [Convert]::ToBase64String($_.RawData, 'InsertLineBreaks') +
    "`n-----END CERTIFICATE-----`n")
}

Fix 1 – NODE_EXTRA_CA_CERTS (Recommended: Process-Wide, Zero Code Change)

NODE_EXTRA_CA_CERTS is a Node.js environment variable that extends the built-in root CA store with additional PEM-encoded certificates. It applies to all HTTPS requests made by the process — https, axios, node-fetch, undici, got, and any other library that uses Node.js's TLS stack. No code changes required.

The variable must be set before the process starts. Changing it at runtime has no effect because TLS initialization reads it once during startup.

# Shell — one-off invocation
NODE_EXTRA_CA_CERTS=./ca-bundle.pem node app.js

# Shell — export for all subsequent commands in the session
export NODE_EXTRA_CA_CERTS=/etc/ssl/certs/corporate-ca.pem
npm run dev

# npm script in package.json
{
  "scripts": {
    "start": "NODE_EXTRA_CA_CERTS=./certs/ca-bundle.pem node dist/server.js",
    "dev":   "NODE_EXTRA_CA_CERTS=./certs/ca-bundle.pem nodemon src/index.ts"
  }
}

# .env file (loaded by dotenv at startup — works because dotenv sets process.env BEFORE
# any TLS connection is made, as long as it's the first require in your entry point)
NODE_EXTRA_CA_CERTS=./certs/ca-bundle.pem

# Docker — in Dockerfile
COPY certs/corporate-ca.pem /app/certs/corporate-ca.pem
ENV NODE_EXTRA_CA_CERTS=/app/certs/corporate-ca.pem

# Docker Compose — in docker-compose.yml
environment:
  - NODE_EXTRA_CA_CERTS=/app/certs/corporate-ca.pem

# GitHub Actions — set as an environment variable in the job
env:
  NODE_EXTRA_CA_CERTS: ${{ github.workspace }}/certs/ca-bundle.pem

# Windows PowerShell
$env:NODE_EXTRA_CA_CERTS = "C:\certs\corporate-ca.pem"
node app.js
PEM file format: The file pointed to by NODE_EXTRA_CA_CERTS can contain multiple certificates — just concatenate them. Each block must be a valid PEM: -----BEGIN CERTIFICATE----- ... -----END CERTIFICATE-----. DER/binary format is not accepted.

Fix 2 – https.Agent with ca: Option (Per-Request / Per-Client Control)

When you need finer-grained control — for example, different CA certificates for different upstream services — create a custom https.Agent with the ca option and pass it to your HTTP client.

Raw https module (CJS)

const https = require('https');
const fs    = require('fs');

// Load the CA certificate (can be a chain / bundle with multiple PEM blocks)
const caCert = fs.readFileSync('./certs/ca-bundle.pem');

const agent = new https.Agent({ ca: caCert });

https.get(
  { hostname: 'internal-api.corp.com', path: '/health', agent },
  (res) => {
    let body = '';
    res.on('data', (chunk) => { body += chunk; });
    res.on('end', () => console.log(body));
  }
).on('error', console.error);

Raw https module (ESM)

import https from 'node:https';
import { readFileSync } from 'node:fs';
import { fileURLToPath } from 'node:url';
import { dirname, join } from 'node:path';

const __dirname = dirname(fileURLToPath(import.meta.url));
const caCert    = readFileSync(join(__dirname, 'certs', 'ca-bundle.pem'));

const agent = new https.Agent({ ca: caCert });

const res = await new Promise((resolve, reject) => {
  const req = https.get(
    { hostname: 'internal-api.corp.com', path: '/health', agent },
    resolve
  );
  req.on('error', reject);
});

console.log(res.statusCode);

Fix 3 – axios with httpsAgent

Pass the custom https.Agent as httpsAgent when creating an axios instance. All requests made through that instance will use the custom CA.

// CJS
const axios = require('axios');
const https = require('https');
const fs    = require('fs');

const httpsAgent = new https.Agent({
  ca: fs.readFileSync('./certs/ca-bundle.pem'),
});

const client = axios.create({ httpsAgent });

const { data } = await client.get('https://internal-api.corp.com/api/v1/users');
console.log(data);
// ESM
import axios from 'axios';
import https from 'node:https';
import { readFileSync } from 'node:fs';

const httpsAgent = new https.Agent({
  ca: readFileSync(new URL('./certs/ca-bundle.pem', import.meta.url)),
});

const client = axios.create({ httpsAgent });

const { data } = await client.get('https://internal-api.corp.com/api/v1/users');
axios default instance: To apply the agent to every axios call globally (including axios.get(), axios.post(), etc. on the default instance), set axios.defaults.httpsAgent = httpsAgent before any request is made.

Fix 4 – node-fetch (v2 and v3)

Pass the agent directly in the fetch options. The API is the same for node-fetch v2 (CJS) and v3 (ESM).

// node-fetch v2 — CJS
const fetch = require('node-fetch');
const https = require('https');
const fs    = require('fs');

const agent = new https.Agent({
  ca: fs.readFileSync('./certs/ca-bundle.pem'),
});

const res  = await fetch('https://internal-api.corp.com/data', { agent });
const data = await res.json();
console.log(data);
// node-fetch v3 — ESM
import fetch from 'node-fetch';
import https from 'node:https';
import { readFileSync } from 'node:fs';

const agent = new https.Agent({
  ca: readFileSync(new URL('./certs/ca-bundle.pem', import.meta.url)),
});

const res  = await fetch('https://internal-api.corp.com/data', { agent });
const data = await res.json();

Fix 5 – Built-in fetch and undici (Node.js 18+)

The built-in fetch in Node.js 18+ is powered by undici. It does not accept an agent option in the same way as node-fetch. The recommended approach is either NODE_EXTRA_CA_CERTS (applies globally, recommended) or setting a custom global dispatcher via undici.setGlobalDispatcher.

// undici dispatcher approach (Node.js 18+ with undici installed separately or via node:)
import { Agent, setGlobalDispatcher } from 'undici';
import { readFileSync } from 'node:fs';

// Set once at application startup, before any fetch() calls
setGlobalDispatcher(new Agent({
  connect: {
    ca: readFileSync('./certs/ca-bundle.pem'),
  },
}));

// All subsequent fetch() calls use the custom CA automatically
const res  = await fetch('https://internal-api.corp.com/data');
const data = await res.json();
// Per-request approach with undici.fetch (bypasses global dispatcher)
import { fetch, Agent } from 'undici';
import { readFileSync } from 'node:fs';

const dispatcher = new Agent({
  connect: { ca: readFileSync('./certs/ca-bundle.pem') },
});

const res  = await fetch('https://internal-api.corp.com/data', { dispatcher });
const data = await res.json();

Fix 6 – Docker and CI Environments

Minimal Docker images (especially Alpine-based) often lack a full CA bundle. Add the ca-certificates package and, if needed, install your corporate CA.

# Dockerfile — Alpine (node:alpine, node:22-alpine, etc.)
FROM node:22-alpine

# Install system CA certificates
RUN apk add --no-cache ca-certificates

# If you have a corporate CA, copy it and update the system bundle
COPY certs/corporate-ca.pem /usr/local/share/ca-certificates/corporate-ca.crt
RUN update-ca-certificates

# Tell Node.js to use it (covers the Node.js TLS stack separately from OS)
ENV NODE_EXTRA_CA_CERTS=/usr/local/share/ca-certificates/corporate-ca.crt

COPY package*.json ./
RUN npm ci --omit=dev
COPY . .
CMD ["node", "dist/server.js"]
# Dockerfile — Debian/Ubuntu (node:22, node:22-slim, etc.)
FROM node:22-slim

RUN apt-get update && apt-get install -y --no-install-recommends ca-certificates && \
    rm -rf /var/lib/apt/lists/*

# Corporate CA
COPY certs/corporate-ca.pem /usr/local/share/ca-certificates/corporate-ca.crt
RUN update-ca-certificates

ENV NODE_EXTRA_CA_CERTS=/usr/local/share/ca-certificates/corporate-ca.crt

COPY package*.json ./
RUN npm ci --omit=dev
COPY . .
CMD ["node", "dist/server.js"]
# GitHub Actions — inject the CA cert from a secret
jobs:
  build:
    runs-on: ubuntu-latest
    env:
      NODE_EXTRA_CA_CERTS: /tmp/corporate-ca.pem
    steps:
      - name: Write corporate CA certificate
        run: echo "${{ secrets.CORPORATE_CA_PEM }}" > /tmp/corporate-ca.pem

      - uses: actions/checkout@v4
      - uses: actions/setup-node@v4
        with:
          node-version: '22'

      - run: npm ci
      - run: npm test

Why rejectUnauthorized: false Is Dangerous

Do not use this in production under any circumstances:

// DANGEROUS — disables ALL TLS certificate verification
// A malicious server or MITM attacker can present any certificate
// and your code will accept it silently.
const agent = new https.Agent({ rejectUnauthorized: false }); // ← NEVER in production
const res = await fetch(url, { agent });

// Equally dangerous:
process.env.NODE_TLS_REJECT_UNAUTHORIZED = '0'; // ← NEVER in production

With rejectUnauthorized: false, an attacker positioned between your Node.js process and the server (for example, on the same network segment, or via DNS spoofing) can present any certificate — including a self-signed one — and your code will complete the TLS handshake without error. The connection appears encrypted, but the attacker can read and modify every byte. API keys, authentication tokens, and sensitive data are all exposed.

If you absolutely must use it during local development (for example, against a dev server with a self-signed cert), add a hard guard that prevents it from reaching other environments:

// Safe local-development-only pattern — crashes if attempted in any other environment
if (process.env.NODE_ENV !== 'development') {
  throw new Error(
    'rejectUnauthorized: false is only allowed in NODE_ENV=development. ' +
    'Set NODE_EXTRA_CA_CERTS instead.'
  );
}

const agent = new https.Agent({ rejectUnauthorized: false });

UNABLE_TO_VERIFY_LEAF_SIGNATURE vs DEPTH_ZERO_SELF_SIGNED_CERT

Error CodeWhat It MeansFix
UNABLE_TO_VERIFY_LEAF_SIGNATURE The server sent a certificate signed by an intermediate CA, but that intermediate CA's certificate was not included in the TLS handshake. Node.js has the leaf but cannot build the chain to a root. Supply the intermediate CA certificate via NODE_EXTRA_CA_CERTS or https.Agent ca:. Fix the server TLS config to send the full chain.
DEPTH_ZERO_SELF_SIGNED_CERT The server sent a certificate that is its own issuer (self-signed — no CA at all). There is no chain to build. Supply the self-signed certificate itself as the trusted CA via NODE_EXTRA_CA_CERTS or https.Agent ca:. Useful for internal dev services and private APIs.
SELF_SIGNED_CERT_IN_CHAIN One of the certificates in the chain (typically the root) is self-signed and not in Node.js's trusted root store. Add the root CA certificate to NODE_EXTRA_CA_CERTS — same approach as above.

Safe CA-Aware Request Patterns

The pattern below creates a single shared agent and client instance at module level, so the PEM file is read once and reused across all requests. This is the recommended production approach.

Shared agent factory (CJS)

// lib/https-client.js — create once, reuse everywhere
const https = require('https');
const fs    = require('fs');
const path  = require('path');
const axios = require('axios');

const CA_PATH = process.env.INTERNAL_CA_PATH
  || path.join(__dirname, '../certs/ca-bundle.pem');

let _agent;
function getAgent() {
  if (!_agent) {
    _agent = new https.Agent({
      ca: fs.readFileSync(CA_PATH),
      keepAlive: true,          // reuse connections for performance
      keepAliveMsecs: 10_000,
    });
  }
  return _agent;
}

const internalClient = axios.create({
  baseURL: process.env.INTERNAL_API_URL,
  httpsAgent: getAgent(),
  timeout: 10_000,
});

module.exports = { internalClient, getAgent };

Shared agent factory (ESM)

// lib/https-client.js (ESM)
import https from 'node:https';
import { readFileSync } from 'node:fs';
import { join, dirname } from 'node:path';
import { fileURLToPath } from 'node:url';
import axios from 'axios';

const __dirname = dirname(fileURLToPath(import.meta.url));
const CA_PATH   = process.env.INTERNAL_CA_PATH
  ?? join(__dirname, '../certs/ca-bundle.pem');

const agent = new https.Agent({
  ca: readFileSync(CA_PATH),
  keepAlive: true,
  keepAliveMsecs: 10_000,
});

export const internalClient = axios.create({
  baseURL: process.env.INTERNAL_API_URL,
  httpsAgent: agent,
  timeout: 10_000,
});

export { agent };

Startup validation

// Validate the CA file is readable at startup rather than failing on first request
import { readFileSync, existsSync } from 'node:fs';

const CA_PATH = process.env.NODE_EXTRA_CA_CERTS;
if (CA_PATH) {
  if (!existsSync(CA_PATH)) {
    throw new Error(
      `NODE_EXTRA_CA_CERTS is set to "${CA_PATH}" but the file does not exist.`
    );
  }
  // Test that it's valid PEM by parsing it
  const pem = readFileSync(CA_PATH, 'utf8');
  if (!pem.includes('-----BEGIN CERTIFICATE-----')) {
    throw new Error(
      `NODE_EXTRA_CA_CERTS file "${CA_PATH}" does not contain a valid PEM certificate.`
    );
  }
  console.log(`[TLS] Using extra CA certificates from: ${CA_PATH}`);
}

Debugging Checklist

  1. Run openssl s_client -connect hostname:443 -showcerts. Count the certificate blocks. If only one, the server is sending an incomplete chain.
  2. Check openssl s_client's "Verify return code" line. Code 21 = unable to verify the first certificate. Code 0 = OK from OpenSSL's perspective (OpenSSL has the intermediate cached — run from a fresh environment).
  3. If the CN in openssl s_client output is your proxy vendor (Zscaler, Palo Alto, etc.) rather than the actual server, you are behind an MITM inspection proxy. Contact IT for the root CA certificate.
  4. Check if the error appears only in CI/CD or Docker but not locally — this almost always indicates a corporate proxy or a missing CA bundle in the container image.
  5. Verify NODE_EXTRA_CA_CERTS is set before the process starts, and the path is correct relative to the working directory. Print process.env.NODE_EXTRA_CA_CERTS at startup to confirm.
  6. Confirm the CA file is PEM format (ASCII text starting with -----BEGIN CERTIFICATE-----), not DER (binary).
  7. For Docker: run docker exec <container> printenv NODE_EXTRA_CA_CERTS and docker exec <container> cat $NODE_EXTRA_CA_CERTS to confirm the file is accessible inside the container.
  8. Check the Node.js version. Node.js 12 and 14 have older root CA bundles. Upgrade to Node.js 18 LTS or 22 LTS if a newer root CA is needed.
  9. Enable TLS debug output: NODE_DEBUG=tls node app.js — this prints the full handshake including which certificates are received and which verification step fails.
# Debug TLS handshake and certificate verification
NODE_DEBUG=tls node app.js 2>&1 | grep -E "(certificate|verify|UNABLE|chain)"

# Show which root CAs Node.js trusts (useful to confirm your CA was added)
node -e "const tls = require('tls'); console.log(tls.rootCertificates.length, 'root CAs loaded');"

# After setting NODE_EXTRA_CA_CERTS, verify the count increases:
NODE_EXTRA_CA_CERTS=./ca-bundle.pem node -e \
  "const tls = require('tls'); console.log(tls.rootCertificates.length, 'root CAs loaded');"

Frequently Asked Questions

What causes UNABLE_TO_VERIFY_LEAF_SIGNATURE in Node.js?

The server sent only its leaf (end-entity) TLS certificate without the intermediate CA certificates needed to build a trust chain to a recognized root CA. Node.js's TLS implementation requires the full chain at handshake time and cannot verify the leaf in isolation. This is a server-side misconfiguration in the most common case, but it also occurs when a corporate HTTPS inspection proxy (Zscaler, Palo Alto, Cisco Umbrella, etc.) inserts its own certificate signed by a private root CA that is not in Node.js's built-in root store.

How do I fix UNABLE_TO_VERIFY_LEAF_SIGNATURE in Node.js?

The correct fix is to provide the missing CA certificate(s). The easiest approach is to set NODE_EXTRA_CA_CERTS=/path/to/ca-bundle.pem before starting Node.js — this extends the built-in root store without replacing it and applies to all HTTPS clients (axios, node-fetch, undici, got, etc.) without code changes. For per-request control, create an https.Agent({ ca: fs.readFileSync('ca-bundle.pem') }) and pass it as httpsAgent (axios), agent (node-fetch), or dispatcher (undici). Never use rejectUnauthorized: false in production.

Why does the error happen in production but not locally?

The most common reasons: (1) A corporate HTTPS inspection proxy is active in production (CI/CD runner, VPN, office network) but not in your local environment. (2) Your browser has cached the intermediate CA from a previous visit; Node.js builds the chain from scratch every time and requires the server to send all intermediates. (3) A load balancer in production strips intermediate certificates that are present in a local dev server. (4) A Docker container in CI uses a minimal image without a full CA bundle. Run openssl s_client -connect hostname:443 -showcerts from inside the failing environment to see exactly what certificates the server is sending.

How is UNABLE_TO_VERIFY_LEAF_SIGNATURE different from DEPTH_ZERO_SELF_SIGNED_CERT?

UNABLE_TO_VERIFY_LEAF_SIGNATURE: the leaf certificate is signed by a CA, but the CA's certificate was not provided in the chain — Node.js cannot build the trust path. The server needs to send intermediate certificates, or you need to supply them via NODE_EXTRA_CA_CERTS.

DEPTH_ZERO_SELF_SIGNED_CERT: the server sent a certificate that is its own signer (issuer = subject). There is no CA chain at all. To trust it, supply the certificate itself as the CA: https.Agent({ ca: fs.readFileSync('server-cert.pem') }).

The fix method is the same for both (supply the relevant certificate as a trusted CA), but the certificate to supply differs: an intermediate CA for UNABLE_TO_VERIFY_LEAF_SIGNATURE, or the self-signed cert itself for DEPTH_ZERO_SELF_SIGNED_CERT.

How do I fix UNABLE_TO_VERIFY_LEAF_SIGNATURE with axios?

Create an https.Agent with the ca option and pass it as httpsAgent to your axios instance:

const httpsAgent = new require('https').Agent({
  ca: require('fs').readFileSync('./certs/ca-bundle.pem'),
});
const client = require('axios').create({ httpsAgent });
const { data } = await client.get('https://internal-api.corp.com/...');

Or set NODE_EXTRA_CA_CERTS before starting the process — no code changes needed.

How do I fix UNABLE_TO_VERIFY_LEAF_SIGNATURE with node-fetch or built-in fetch?

For node-fetch v2/v3: fetch(url, { agent: new https.Agent({ ca: fs.readFileSync('ca.pem') }) }).

For built-in fetch (Node.js 18+, undici): use NODE_EXTRA_CA_CERTS (recommended), or use setGlobalDispatcher(new Agent({ connect: { ca: fs.readFileSync('ca.pem') } })) from the undici package, or use undici.fetch(url, { dispatcher: new Agent({ connect: { ca: ... } }) }) for per-request control.

Is rejectUnauthorized: false safe to use?

No. rejectUnauthorized: false (or NODE_TLS_REJECT_UNAUTHORIZED=0) disables every certificate check — server identity, expiry, revocation, and chain validation. Any server, including one performing a man-in-the-middle attack, will be accepted. Your API credentials, tokens, and data are transmitted over a connection you believe is encrypted, but an attacker can read and modify everything. Use rejectUnauthorized: false only in isolated local development with no sensitive data, always behind a guard like if (process.env.NODE_ENV !== 'development') throw.

How do I get the CA certificate to use with NODE_EXTRA_CA_CERTS?

Run openssl s_client -connect hostname:443 -showcerts 2>/dev/null < /dev/null and save the non-leaf certificate blocks (everything after the first END CERTIFICATE) to a .pem file. For corporate MITM proxies, ask your IT/security team for the root CA certificate in PEM format, or export it from the OS certificate store (Linux: /etc/ssl/certs/ca-certificates.crt; macOS: security find-certificate; Windows: PowerShell Get-ChildItem Cert:\LocalMachine\Root).

Why does NODE_EXTRA_CA_CERTS not work when I set it inside my code?

NODE_EXTRA_CA_CERTS is read once when the Node.js TLS subsystem initializes — setting process.env.NODE_EXTRA_CA_CERTS programmatically inside your application after the process has started has no effect on TLS connections. You must set the variable in the process environment before the Node.js process starts: in the shell, in a .env file loaded by dotenv as the very first operation in the entry point, in the Docker ENV directive, or in the CI environment variable settings.

Related Errors