Node.js TLS Error

Error: self-signed certificate

error code: DEPTH_ZERO_SELF_SIGNED_CERT  ·  also: Error: self signed certificate  ·  self-signed certificate in certificate chain

Complete reference — root cause (server presents a self-signed cert Node.js's CA store doesn't trust), correct fix with NODE_EXTRA_CA_CERTS and https.Agent({ ca: ... }), axios / node-fetch / undici specifics, npm corporate proxy, Docker microservice scenarios, mkcert for local HTTPS dev, and why rejectUnauthorized: false must never be used in production.

Quick Answer: The server's TLS certificate is self-signed — its issuer equals its own subject, so there is no Certificate Authority chain. Node.js rejects it because it has no way to verify the server's identity. Fix by supplying the self-signed certificate itself as a trusted CA: NODE_EXTRA_CA_CERTS=./server-cert.pem (process-wide, zero code changes) or https.Agent({ ca: fs.readFileSync('server-cert.pem') }). Never set rejectUnauthorized: false or NODE_TLS_REJECT_UNAUTHORIZED=0 in production — that disables all TLS security.

What is DEPTH_ZERO_SELF_SIGNED_CERT?

DEPTH_ZERO_SELF_SIGNED_CERT is an OpenSSL error code that Node.js surfaces during a TLS handshake when the server presents a certificate where the Issuer field is identical to the Subject field — the certificate signed itself, with no Certificate Authority involved.

"Depth zero" refers to the position in the certificate chain. Position zero is always the server's own certificate (the leaf). A depth-zero self-signed cert means the very first certificate in the chain — the server's own — is self-signed. There is no chain to build; no CA exists.

Node.js validates TLS certificates against its built-in root CA store (sourced from the Mozilla CA bundle). A self-signed certificate is never in that store. Node.js therefore cannot establish that the server is who it claims to be and throws this error. This is by design — it is not a Node.js bug, and it is not caused by your code being wrong.

Despite being one of the most common TLS errors developers encounter — especially during local HTTPS development, internal microservices, or npm behind a corporate proxy — it is not documented in the official Node.js error reference. This page fills that gap.

Exact error strings you will see in the terminal:
Error: self-signed certificate
Error: self signed certificate
FetchError: request to https://... failed, reason: self-signed certificate
AxiosError: self-signed certificate
RequestError: self-signed certificate
TypeError: fetch failed (with cause.code: 'DEPTH_ZERO_SELF_SIGNED_CERT')

Full Error Example

Error: self-signed 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: 'DEPTH_ZERO_SELF_SIGNED_CERT'
}

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

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: self-signed certificate
      at TLSSocket.onConnectEnd (node:_tls_wrap:1564:19) {
    code: 'DEPTH_ZERO_SELF_SIGNED_CERT'
  }
}

With axios:

AxiosError: self-signed certificate
    at RedirectableRequest._currentRequest.emit (.../node_modules/follow-redirects/index.js:657:22)
    at TLSSocket.socketErrorListener (node:_http_client:481:9) {
  errno: -60,
  code: 'DEPTH_ZERO_SELF_SIGNED_CERT',
  config: { url: 'https://internal.example.com/api', ... },
  request: <ref *1> ClientRequest { ... }
}

Diagnostic Fields

PropertyValueMeaning
err.code'DEPTH_ZERO_SELF_SIGNED_CERT'OpenSSL error: server certificate at depth 0 is self-signed and not trusted
err.message'self-signed certificate'Human-readable OpenSSL reason string
err.library'SSL routines'OpenSSL subsystem that raised the error
err.reason'self signed certificate'Same as message; set on older OpenSSL builds
err.syscall'connect'Failed during TLS handshake, not during data transfer
err.hoste.g. 'internal.corp.com'Hostname of the server that presented the self-signed cert

All Causes at a Glance

CauseSymptom / ContextFix
Local HTTPS development server (Vite, webpack-dev-server, Next.js) Dev server generates a self-signed cert automatically for localhost. Browser warns you and lets you proceed; Node.js rejects it outright. Use mkcert to issue a locally-trusted cert, or set NODE_EXTRA_CA_CERTS to the dev server's self-signed cert.
Internal microservice or Docker service with a self-signed cert A backend service in the same Docker Compose network uses HTTPS with a self-generated certificate. The calling service throws this error. Copy the self-signed cert into the calling container's image; set NODE_EXTRA_CA_CERTS to its path.
npm behind a corporate proxy with a self-signed root CA npm install fails with DEPTH_ZERO_SELF_SIGNED_CERT. The proxy re-signs all HTTPS traffic with its own certificate. npm config set cafile /path/to/corporate-ca.pem. Also set NODE_EXTRA_CA_CERTS for Node.js code.
Private API or staging endpoint with a self-generated certificate An internal staging API, monitoring endpoint, or private registry uses a self-signed cert for convenience. Set NODE_EXTRA_CA_CERTS to the API's certificate. For shared teams, distribute the cert via a secrets manager or Docker build arg.
Kubernetes internal service with TLS but no cert-manager A service inside a Kubernetes cluster is accessed via HTTPS using a self-signed cert created manually without cert-manager or Let's Encrypt. Set NODE_EXTRA_CA_CERTS in the Pod spec via a ConfigMap or Secret. Long-term: use cert-manager with Let's Encrypt or a private CA.
NODE_TLS_REJECT_UNAUTHORIZED=0 removed in production but forgotten locally Works locally because .env disables TLS checking. Fails in CI/Docker because the env var is absent — the real error surfaces for the first time. Do not suppress the error with NODE_TLS_REJECT_UNAUTHORIZED=0. Supply the certificate instead. Audit all .env files for this variable.

Cause 1 – Server Presents a Self-Signed Certificate

A self-signed certificate is one where the entity that issued the certificate is the same entity the certificate belongs to. In X.509 terms: Issuer == Subject. There is no Certificate Authority involved — the server generated a key pair and signed its own certificate with its own private key.

This is the typical setup for local HTTPS development servers, test environments, internal tools, and any service where "just get HTTPS working" was the goal. The certificate provides encryption (nobody can eavesdrop on the wire) but provides no identity verification (you cannot confirm the server is who it claims to be from the certificate alone).

Node.js's TLS implementation (powered by OpenSSL) performs both encryption and identity verification during the handshake. It checks whether the certificate was signed by a CA in its built-in root store. A self-signed certificate fails this check immediately — OpenSSL returns error code 18 (X509_V_ERR_DEPTH_ZERO_SELF_SIGNED_CERT), which Node.js exposes as DEPTH_ZERO_SELF_SIGNED_CERT.

Confirm with openssl s_client

# Inspect the certificate the server sends
openssl s_client -connect localhost:3000 -showcerts

# Look for:
# Verify return code: 18 (self signed certificate)
# AND: Issuer and Subject lines that are identical

# Quick one-liner check:
echo | openssl s_client -connect localhost:3000 2>/dev/null | grep -E "Issuer:|Subject:|Verify return code"

# Expected output for a self-signed cert:
# subject=CN = localhost
# issuer=CN = localhost          ← same as subject = self-signed
# Verify return code: 18 (self signed certificate)

Extract the self-signed certificate to a PEM file

# Save the server's public certificate (safe to distribute — public key only)
openssl s_client -connect localhost:3000 2>/dev/null </dev/null \
  | openssl x509 -out server-cert.pem

# Verify what you extracted:
openssl x509 -in server-cert.pem -text -noout | grep -E "Issuer:|Subject:|Not After"

# Expected: Issuer == Subject (confirms self-signed)

Cause 2 – Local HTTPS Dev Server (Vite, webpack-dev-server, Next.js)

Modern frontend tooling can serve HTTPS locally for testing service workers, mixed-content rules, or cookie Secure flags. The easiest path is a self-signed certificate, which is what many tools generate by default.

If your Node.js application (an API route test, a Playwright test, a script that calls https://localhost:5173) makes an HTTPS request to this dev server, it hits DEPTH_ZERO_SELF_SIGNED_CERT.

The better solution for local HTTPS: mkcert. mkcert creates a real local CA, installs it into the OS trust store (so browsers trust it too), and issues certificates signed by that CA. Node.js processes that set NODE_EXTRA_CA_CERTS to the mkcert CA root also trust those certificates — without any rejectUnauthorized: false anywhere.
# Install mkcert (macOS via Homebrew)
brew install mkcert

# Install mkcert (Windows via Chocolatey)
choco install mkcert

# Install mkcert (Linux)
# see https://github.com/FiloSottile/mkcert for distro-specific instructions

# Create and trust the local CA (run once per machine)
mkcert -install

# Issue a certificate for localhost and 127.0.0.1
mkcert localhost 127.0.0.1
# Creates: localhost+1.pem (certificate) and localhost+1-key.pem (private key)

# Tell Node.js processes to trust the mkcert CA
export NODE_EXTRA_CA_CERTS="$(mkcert -CAROOT)/rootCA.pem"
# Or in .env (loaded before any TLS calls):
# NODE_EXTRA_CA_CERTS=/Users/you/Library/Application Support/mkcert/rootCA.pem

# Windows PowerShell
$env:NODE_EXTRA_CA_CERTS = "$(mkcert -CAROOT)\rootCA.pem"

Cause 3 – Docker Internal Services and Microservices

In Docker Compose or Kubernetes, services communicate over an internal network. If a service (database admin panel, internal API, message queue dashboard) uses HTTPS with a self-signed certificate, any Node.js container that calls it over HTTPS will encounter DEPTH_ZERO_SELF_SIGNED_CERT.

The certificate needs to be distributed to all consuming containers and trusted at the Node.js level via NODE_EXTRA_CA_CERTS.

# Dockerfile for the consuming service
FROM node:22-alpine

# Install system CA tools (needed if you also want OS-level trust)
RUN apk add --no-cache ca-certificates

# Copy the self-signed cert from the internal service
# (typically obtained during image build from a shared certs/ directory)
COPY certs/internal-api-cert.pem /app/certs/internal-api-cert.pem

# Tell Node.js to trust it
ENV NODE_EXTRA_CA_CERTS=/app/certs/internal-api-cert.pem

COPY package*.json ./
RUN npm ci --omit=dev
COPY . .
CMD ["node", "dist/server.js"]
# docker-compose.yml — pass the cert as an environment variable
services:
  api-consumer:
    build: .
    environment:
      - NODE_EXTRA_CA_CERTS=/app/certs/internal-api-cert.pem
    volumes:
      - ./certs:/app/certs:ro   # mount the certs directory read-only

  internal-api:
    image: my-internal-api
    # ... uses self-signed cert at startup
# Kubernetes Pod spec — inject the cert via a ConfigMap
apiVersion: v1
kind: ConfigMap
metadata:
  name: internal-ca-certs
data:
  internal-api-cert.pem: |
    -----BEGIN CERTIFICATE-----
    MIIDXTCCAkWgAwIBAgIJANOUXUaLRPXZMA0GCSqGSIb3DQEBCwUAMEUxCzAJBgNV
    ... (your cert contents here)
    -----END CERTIFICATE-----
---
apiVersion: apps/v1
kind: Deployment
spec:
  template:
    spec:
      containers:
        - name: api-consumer
          env:
            - name: NODE_EXTRA_CA_CERTS
              value: /etc/certs/internal-api-cert.pem
          volumeMounts:
            - name: ca-certs
              mountPath: /etc/certs
      volumes:
        - name: ca-certs
          configMap:
            name: internal-ca-certs

Cause 4 – npm Behind a Corporate Proxy with a Self-Signed CA

Corporate networks often route all HTTPS traffic through an inspection proxy that re-signs connections with the company's own root CA certificate. This root CA is not in Node.js's built-in store.

When the proxy uses a self-signed root CA (rather than one signed by a trusted external CA), npm install and any HTTPS request from Node.js will fail with DEPTH_ZERO_SELF_SIGNED_CERT.

Important: npm has its own certificate configuration separate from NODE_EXTRA_CA_CERTS. You need to fix both independently.

# Fix npm — set the cafile npm config option (persists in ~/.npmrc)
npm config set cafile /path/to/corporate-ca.pem

# Verify it was set:
npm config get cafile

# Fix Node.js code — set NODE_EXTRA_CA_CERTS
export NODE_EXTRA_CA_CERTS=/path/to/corporate-ca.pem

# Both can be set together in a .npmrc file at the project root:
# cafile=/path/to/corporate-ca.pem
# NODE_EXTRA_CA_CERTS is an env var, not an npmrc key — set it in your shell profile

# Windows PowerShell
npm config set cafile C:\certs\corporate-ca.pem
$env:NODE_EXTRA_CA_CERTS = "C:\certs\corporate-ca.pem"
Never use npm config set strict-ssl false. This is the npm equivalent of rejectUnauthorized: false — it disables all TLS certificate verification for every npm registry request. An attacker on the same network could serve malicious packages and npm would accept them silently.

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

NODE_EXTRA_CA_CERTS is the cleanest fix. It extends Node.js's built-in root CA store with additional certificates. For self-signed certs, you supply the self-signed certificate itself — because there is no separate CA, the certificate is the CA.

The variable must be set before the process starts. Setting process.env.NODE_EXTRA_CA_CERTS inside running code has no effect.

# Shell — one-off invocation
NODE_EXTRA_CA_CERTS=./server-cert.pem node app.js

# Shell — export for the session
export NODE_EXTRA_CA_CERTS=/home/user/certs/internal-service.pem
npm run dev

# npm script (package.json) — cross-platform with cross-env
{
  "scripts": {
    "dev":   "cross-env NODE_EXTRA_CA_CERTS=./certs/server-cert.pem nodemon src/index.ts",
    "start": "cross-env NODE_EXTRA_CA_CERTS=./certs/server-cert.pem node dist/server.js"
  }
}

# .env file — works when dotenv is the very first require/import in your entry point
NODE_EXTRA_CA_CERTS=./certs/server-cert.pem

# Dockerfile
COPY certs/server-cert.pem /app/certs/server-cert.pem
ENV NODE_EXTRA_CA_CERTS=/app/certs/server-cert.pem

# Docker Compose environment section
environment:
  - NODE_EXTRA_CA_CERTS=/app/certs/server-cert.pem

# GitHub Actions
env:
  NODE_EXTRA_CA_CERTS: ${{ github.workspace }}/certs/server-cert.pem

# Windows PowerShell
$env:NODE_EXTRA_CA_CERTS = "C:\repos\myapp\certs\server-cert.pem"
node app.js
Multiple certificates: NODE_EXTRA_CA_CERTS accepts a single PEM file. To trust multiple self-signed certs, concatenate them into one file: cat cert-a.pem cert-b.pem > combined-ca.pem. Each -----BEGIN CERTIFICATE----- ... -----END CERTIFICATE----- block is loaded as a separate trusted CA.

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

When you need to trust a specific self-signed certificate only for certain requests (for example, one internal service while all other requests use the standard CA store), create a custom https.Agent with the ca option.

Raw https module (CJS)

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

// The self-signed certificate IS the CA — supply it directly
const selfSignedCert = fs.readFileSync('./certs/internal-service-cert.pem');

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

https.get(
  { hostname: 'internal.corp.com', port: 8443, 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 selfSignedCert = readFileSync(join(__dirname, 'certs', 'internal-service-cert.pem'));

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

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

console.log(res.statusCode);
ca: replaces, not extends: The ca option in https.Agent replaces the default Node.js CA store — it does not extend it. If you also need to connect to public HTTPS servers through the same agent, you must include the standard root CAs: const agent = new https.Agent({ ca: [selfSignedCert, ...tls.rootCertificates] }); For most use cases it is cleaner to create a separate agent per upstream service.

Fix 3 – axios with httpsAgent

Pass the custom https.Agent as httpsAgent when creating an axios instance. All requests through that instance will use the self-signed certificate as a trusted CA.

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

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

const internalClient = axios.create({
  baseURL: 'https://internal.corp.com:8443',
  httpsAgent,
  timeout: 10_000,
});

const { data } = await internalClient.get('/api/v1/status');
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/internal-service-cert.pem', import.meta.url)),
});

const internalClient = axios.create({
  baseURL: 'https://internal.corp.com:8443',
  httpsAgent,
  timeout: 10_000,
});

const { data } = await internalClient.get('/api/v1/status');
Global axios default: To apply the agent to all axios calls including the default instance, set axios.defaults.httpsAgent = httpsAgent before making any request. This affects every axios.get(), axios.post(), etc. in the process.

Fix 4 – node-fetch (v2 and v3)

// 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/internal-service-cert.pem'),
});

const res  = await fetch('https://internal.corp.com:8443/api/status', { 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/internal-service-cert.pem', import.meta.url)),
});

const res  = await fetch('https://internal.corp.com:8443/api/status', { 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 and does not accept an agent option in the same way as node-fetch. Use NODE_EXTRA_CA_CERTS (recommended) or set a global undici dispatcher.

// undici global dispatcher — set once at startup before any fetch() calls
import { Agent, setGlobalDispatcher } from 'undici';
import { readFileSync } from 'node:fs';

setGlobalDispatcher(new Agent({
  connect: {
    ca: readFileSync('./certs/internal-service-cert.pem'),
  },
}));

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

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

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

Why rejectUnauthorized: false Is Dangerous

rejectUnauthorized: false is the first result many developers find when searching for this error. It makes the error go away immediately. It is also a serious security vulnerability in any environment beyond an isolated local machine.

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

// Equally dangerous — affects the ENTIRE process, all HTTPS requests:
process.env.NODE_TLS_REJECT_UNAUTHORIZED = '0'; // ← NEVER in production

// Also dangerous via environment (watch your .env files):
// NODE_TLS_REJECT_UNAUTHORIZED=0

With verification disabled, any server — including one controlled by an attacker performing a man-in-the-middle attack via ARP spoofing, DNS poisoning, or a compromised network — can present any certificate and your application will complete the handshake. The connection appears encrypted but the attacker reads and modifies every byte. API keys, session tokens, database credentials, and user data transmitted over the "secure" connection are fully exposed.

If you absolutely must use it during local development (not recommended — use mkcert instead), add a hard guard that prevents it from reaching any other environment:

// Safe local-development-only pattern with hard guard
if (process.env.NODE_ENV !== 'development') {
  throw new Error(
    'rejectUnauthorized: false is only permitted in NODE_ENV=development. ' +
    'Supply the server certificate via NODE_EXTRA_CA_CERTS instead.'
  );
}

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

// Prefer mkcert instead — then you never need rejectUnauthorized: false at all.

Generating a Self-Signed Certificate for Internal Use

If you control the server side and need to generate a self-signed certificate for a new internal service, use openssl or the recommended mkcert approach.

openssl — minimal self-signed cert (90 days, localhost)

# Generate a private key and self-signed certificate in one command
openssl req -x509 -newkey rsa:4096 -keyout server-key.pem -out server-cert.pem \
  -sha256 -days 90 -nodes \
  -subj "/CN=localhost" \
  -addext "subjectAltName=DNS:localhost,IP:127.0.0.1"

# server-cert.pem — distribute to all clients that need to trust this server
# server-key.pem  — keep private, used by the server only

# On Node.js server side:
const https = require('https');
const fs    = require('fs');

https.createServer(
  {
    key:  fs.readFileSync('./server-key.pem'),
    cert: fs.readFileSync('./server-cert.pem'),
  },
  (req, res) => res.end('Hello over HTTPS!')
).listen(3000);

mkcert — trusted local certificate (recommended for local dev)

# mkcert creates a local CA and issues certs trusted by OS/browsers/Node.js
mkcert -install          # installs the local CA into OS trust store (run once)
mkcert localhost 127.0.0.1 ::1

# Outputs: localhost+2.pem and localhost+2-key.pem
# These certs are trusted by Chrome, Firefox, Safari, and curl (which uses OS store)

# For Node.js to also trust them, set NODE_EXTRA_CA_CERTS:
export NODE_EXTRA_CA_CERTS="$(mkcert -CAROOT)/rootCA.pem"

# Use in the server:
const https = require('https');
const fs    = require('fs');

https.createServer(
  {
    key:  fs.readFileSync('./localhost+2-key.pem'),
    cert: fs.readFileSync('./localhost+2.pem'),
  },
  (req, res) => res.end('Hello — no certificate errors!')
).listen(3000);

// Any Node.js client with NODE_EXTRA_CA_CERTS set can now connect without errors:
const res = await fetch('https://localhost:3000');
console.log(await res.text()); // 'Hello — no certificate errors!'

DEPTH_ZERO_SELF_SIGNED_CERT vs UNABLE_TO_VERIFY_LEAF_SIGNATURE

Error CodeWhat It MeansWhat to Supply as CA
DEPTH_ZERO_SELF_SIGNED_CERT The server's own certificate is self-signed. Issuer == Subject. No CA chain exists at all. The server is the root. Supply the server certificate itself as the trusted CA: ca: fs.readFileSync('server-cert.pem')
UNABLE_TO_VERIFY_LEAF_SIGNATURE The server sent a certificate signed by a real CA, but the intermediate CA's certificate was not included in the TLS handshake. Node.js cannot build the path to a trusted root. Supply the intermediate CA certificate (not the server certificate itself): extract it from openssl s_client -showcerts output.
SELF_SIGNED_CERT_IN_CHAIN The chain includes a self-signed certificate (typically the root CA) that is not in Node.js's trusted root store. Common with private/internal CAs. Supply the root CA certificate of the internal/private CA that signed the chain.
Quick diagnostic rule: Run openssl s_client -connect host:port 2>/dev/null | grep -E "Issuer:|Subject:". If Issuer == Subject → DEPTH_ZERO_SELF_SIGNED_CERT (supply the cert itself). If Issuer != Subject but the chain is short → UNABLE_TO_VERIFY_LEAF_SIGNATURE (supply the intermediate CA).

Safe CA-Aware Request Patterns

Create a shared agent once at module load time. Read the PEM file once; reuse the agent across all requests. Validate at startup so failures are immediate and obvious.

Shared internal client factory (CJS)

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

const CERT_PATH = process.env.INTERNAL_SERVICE_CA
  || path.join(__dirname, '../certs/internal-service-cert.pem');

// Validate at startup — fail immediately rather than on first request
if (!fs.existsSync(CERT_PATH)) {
  throw new Error(
    `[TLS] Certificate file not found: ${CERT_PATH}\n` +
    `Set INTERNAL_SERVICE_CA to the path of the internal service's self-signed certificate.`
  );
}

const caCert = fs.readFileSync(CERT_PATH);
const pem    = caCert.toString('utf8');
if (!pem.includes('-----BEGIN CERTIFICATE-----')) {
  throw new Error(`[TLS] File at ${CERT_PATH} is not a valid PEM certificate.`);
}

console.log(`[TLS] Trusting internal CA from: ${CERT_PATH}`);

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

const internalClient = axios.create({
  baseURL: process.env.INTERNAL_API_URL || 'https://internal.corp.com:8443',
  httpsAgent: agent,
  timeout: 10_000,
});

module.exports = { internalClient, agent };

Shared internal client factory (ESM)

// lib/internal-https-client.js (ESM)
import https from 'node:https';
import { readFileSync, existsSync } 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 CERT_PATH = process.env.INTERNAL_SERVICE_CA
  ?? join(__dirname, '../certs/internal-service-cert.pem');

if (!existsSync(CERT_PATH)) {
  throw new Error(
    `[TLS] Certificate file not found: ${CERT_PATH}\n` +
    `Set INTERNAL_SERVICE_CA to the path of the internal service's self-signed certificate.`
  );
}

const caCert = readFileSync(CERT_PATH);
console.log(`[TLS] Trusting internal CA from: ${CERT_PATH}`);

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

export const internalClient = axios.create({
  baseURL: process.env.INTERNAL_API_URL ?? 'https://internal.corp.com:8443',
  httpsAgent: agent,
  timeout: 10_000,
});

Debugging Checklist

  1. Run openssl s_client -connect hostname:port -showcerts. Check if "Issuer" equals "Subject" in the first certificate block — if so, it is self-signed and you need to trust that certificate specifically.
  2. Check openssl s_client's "Verify return code" line. Code 18 = self-signed certificate. Code 21 = unable to verify the first certificate (chain incomplete — different error).
  3. Confirm NODE_EXTRA_CA_CERTS is set before the process starts. Print process.env.NODE_EXTRA_CA_CERTS at startup and verify the file exists and is readable.
  4. Verify the PEM file contains a valid certificate block: cat server-cert.pem | grep "BEGIN CERTIFICATE". A DER (binary) file will not work.
  5. If using https.Agent({ ca: ... }) — remember the ca option replaces the default CA store. If you also need to reach public HTTPS endpoints through the same agent, include tls.rootCertificates.
  6. For Docker: run docker exec <container> printenv NODE_EXTRA_CA_CERTS and docker exec <container> cat $NODE_EXTRA_CA_CERTS to confirm the variable is set and the file is accessible inside the container.
  7. For npm errors: check npm config get cafile. NODE_EXTRA_CA_CERTS fixes Node.js HTTPS code; for npm itself you also need npm config set cafile.
  8. Enable TLS debug logging for the full handshake trace: NODE_DEBUG=tls node app.js 2>&1 | grep -E "(certificate|self.signed|DEPTH)".
  9. Verify the Node.js root CA count to confirm your cert was added: node -e "const tls=require('tls'); console.log(tls.rootCertificates.length)" vs NODE_EXTRA_CA_CERTS=./server-cert.pem node -e "..." — the count should increase by 1.
  10. Search your codebase and .env files for NODE_TLS_REJECT_UNAUTHORIZED and rejectUnauthorized: false — remove them and supply the certificate instead.

Frequently Asked Questions

What causes DEPTH_ZERO_SELF_SIGNED_CERT in Node.js?

The server presented a TLS certificate that is self-signed — its Issuer and Subject fields are identical, meaning no Certificate Authority signed it. "Depth zero" refers to the server's own certificate (position 0 in the chain). Node.js cannot trust it because it is not in the built-in Mozilla root CA store, and there is no chain to a trusted root. This is intentional security behavior. Common scenarios: a local HTTPS development server, an internal microservice or Docker container running HTTPS with a generated certificate, npm behind a corporate proxy with a self-signed root CA, or a private API endpoint using a self-generated certificate.

How do I fix DEPTH_ZERO_SELF_SIGNED_CERT in Node.js?

The correct fix is to supply the self-signed certificate itself as a trusted CA. Because the certificate is both the server certificate and the root (it signed itself), you trust it by adding it to Node.js's CA store. The easiest approach: set NODE_EXTRA_CA_CERTS=/path/to/server-cert.pem before starting Node.js — applies to all HTTPS clients (axios, node-fetch, undici, got) without code changes. For per-request control: new https.Agent({ ca: fs.readFileSync('server-cert.pem') }). Never set rejectUnauthorized: false or NODE_TLS_REJECT_UNAUTHORIZED=0 in production.

How is DEPTH_ZERO_SELF_SIGNED_CERT different from UNABLE_TO_VERIFY_LEAF_SIGNATURE?

DEPTH_ZERO_SELF_SIGNED_CERT: the server's own certificate is self-signed (Issuer == Subject). No CA chain exists. To trust it, supply the certificate itself as the CA: ca: fs.readFileSync('server-cert.pem').

UNABLE_TO_VERIFY_LEAF_SIGNATURE: the server certificate was signed by a real intermediate CA, but that intermediate CA's certificate was not included in the TLS handshake. Node.js cannot build the chain to a trusted root. To fix, supply the intermediate CA certificate — not the server certificate.

Quick diagnostic: run openssl s_client -connect host:port. If Issuer == Subject → DEPTH_ZERO_SELF_SIGNED_CERT. If Issuer differs from Subject and chain is short → UNABLE_TO_VERIFY_LEAF_SIGNATURE.

Why does DEPTH_ZERO_SELF_SIGNED_CERT happen in Docker but not locally?

The most common reason: locally you have NODE_TLS_REJECT_UNAUTHORIZED=0 in a .env file that is not present in Docker, so the real error surfaces for the first time in Docker. Other causes: (1) The Docker container calls an internal service over HTTPS using a self-signed cert, but the container image does not have that cert configured. (2) The container runs in a different network where a corporate proxy intercepts HTTPS with a self-signed CA. (3) The container uses a minimal base image without the same OS-level certificates as your local machine. Fix by copying the certificate into the Docker image and setting NODE_EXTRA_CA_CERTS in the Dockerfile or Compose environment.

How do I fix DEPTH_ZERO_SELF_SIGNED_CERT with axios?

Create an https.Agent with the ca option pointing to the self-signed certificate PEM file, then pass it as httpsAgent:

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

Or set NODE_EXTRA_CA_CERTS=./certs/server-cert.pem before starting the process — no code changes needed.

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

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

For built-in fetch (Node.js 18+, undici): use NODE_EXTRA_CA_CERTS (recommended, applies automatically), or use setGlobalDispatcher(new Agent({ connect: { ca: fs.readFileSync('server-cert.pem') } })) from the undici package, or use undici.fetch(url, { dispatcher: new Agent({ connect: { ca: ... } }) }) for per-request control. The built-in fetch does not accept an https.Agent directly.

Is rejectUnauthorized: false safe to use?

No. rejectUnauthorized: false (or NODE_TLS_REJECT_UNAUTHORIZED=0) disables all TLS certificate verification. Any server — including one performing a man-in-the-middle attack — can present any certificate and will be accepted. Your API credentials, tokens, and data are fully exposed to interception and modification even though the connection appears encrypted. Acceptable only in a completely isolated local development environment with no sensitive data, and only when you have explicitly verified no better alternative exists (mkcert is almost always a better alternative). Always guard with if (process.env.NODE_ENV !== 'development') throw.

How do I fix npm DEPTH_ZERO_SELF_SIGNED_CERT behind a corporate proxy?

npm manages its own certificate trust separately from NODE_EXTRA_CA_CERTS. For npm, run: npm config set cafile /path/to/corporate-ca.pem. This persists in ~/.npmrc and applies to all npm operations. You can also set it per-project in a .npmrc file: cafile=/path/to/corporate-ca.pem. Additionally set NODE_EXTRA_CA_CERTS for your Node.js application code. Never use npm config set strict-ssl false — it disables all TLS checking for npm, equivalent to rejectUnauthorized: false.

How do I generate a self-signed certificate that Node.js can trust?

The recommended approach for local development is mkcert: mkcert -install (creates and installs a local CA), then mkcert localhost 127.0.0.1 (issues a certificate). Set NODE_EXTRA_CA_CERTS=$(mkcert -CAROOT)/rootCA.pem for Node.js to trust it. For server-to-server internal services, use openssl req -x509 -newkey rsa:4096 -keyout server-key.pem -out server-cert.pem -sha256 -days 365 -nodes -subj "/CN=internal-service.corp.com" -addext "subjectAltName=DNS:internal-service.corp.com". Distribute server-cert.pem (the public certificate) to all clients that need to trust this server, and set their NODE_EXTRA_CA_CERTS to point to it.

Related Errors