LEARN · DEBUGGING GUIDE

Node.js Self-Signed Certificate Error: Debugging TLS_CERTIFICATE_VERIFY_FAILED

If your Node.js app throws 'UNABLE_TO_VERIFY_LEAF_SIGNATURE' or 'SELF_SIGNED_CERT_IN_CHAIN', it's not just about setting NODE_TLS_REJECT_UNAUTHORIZED=0. This guide walks through the real causes—from missing intermediate CAs to expired root stores—and how to fix them without disabling security.

IntermediateHTTP / Networking8 min read

What this usually means

Node.js uses a built-in list of trusted Certificate Authorities (CAs) from the Mozilla NSS store. When it encounters a certificate that is not signed by any CA in that store, or if the certificate chain is incomplete (missing intermediate certificates), it rejects the connection with a TLS error. Self-signed certificates are the most common cause, but expired CAs, missing intermediate certs, or incorrectly configured NODE_EXTRA_CA_CERTS also trigger this. The error is Node's way of saying 'I cannot verify the identity of this server'.

( 01 )Fast diagnosis

The first ten minutes — establish facts before touching code.

  • 1Run `openssl s_client -connect <host>:<port> -showcerts` to inspect the server's certificate chain and see if it sends intermediate certs.
  • 2Check if the error message includes 'SELF_SIGNED_CERT_IN_CHAIN' or 'UNABLE_TO_VERIFY_LEAF_SIGNATURE'—they point to different root causes.
  • 3Temporarily set `NODE_TLS_REJECT_UNAUTHORIZED=0` in the environment to confirm the certificate is the issue (but never do this in production).
  • 4Compare the certificate's issuer and subject using `openssl x509 -in cert.pem -text -noout` against the root CA you trust.
  • 5Test the same request with cURL using `curl -v https://host:port` to see if cURL trusts the certificate (it may have a different CA store).
  • 6If using a corporate internal CA, verify that the CA certificate is added to the system's trust store AND to NODE_EXTRA_CA_CERTS.
( 02 )Where to look

The specific files, logs, configs, and dashboards that usually own this bug.

  • searchNode.js environment variables: `NODE_TLS_REJECT_UNAUTHORIZED`, `NODE_EXTRA_CA_CERTS`
  • searchThe server's TLS configuration file (e.g., nginx `ssl_certificate`, `ssl_trusted_certificate`)
  • searchThe certificate files themselves: server cert, intermediate chain, root CA
  • searchApplication code: where HTTPS agent is created (e.g., `new https.Agent({ca: ...})`)
  • searchSystem CA bundle: `/etc/ssl/certs/ca-certificates.crt` (Linux) or `Security.framework` (macOS)
  • searchDocker container's CA store if running in containers
  • searchCI/CD pipeline configuration that may inject custom CA bundles
( 03 )Common root causes

Practical causes, not theory. These are the things you will actually find.

  • warningThe server only sends its leaf certificate and not the intermediate CA chain, so Node.js cannot build a trust path.
  • warningThe certificate is self-signed and not added to Node.js trust store (either via NODE_EXTRA_CA_CERTS or the system store).
  • warningThe system CA store is outdated or missing the root CA that signed the server certificate (e.g., an internal corporate CA).
  • warningThe server certificate has expired or is not yet valid (clock skew on server or client).
  • warningThe application overrides the default HTTPS agent with a `ca` option that doesn't include the necessary root or intermediate.
  • warningEnvironment variable `NODE_TLS_REJECT_UNAUTHORIZED` is incorrectly set to '0' in development but missing in production, masking the real fix.
( 04 )Fix patterns

Concrete fix directions. Pick the one that matches your root cause.

  • buildConfigure the server to send the full certificate chain: concatenate the server cert, then intermediate certs, then root (if needed) in the `ssl_certificate` file.
  • buildSet `NODE_EXTRA_CA_CERTS` to a file containing the self-signed or internal CA certificate, without disabling `NODE_TLS_REJECT_UNAUTHORIZED`.
  • buildIn the application code, create a custom `https.Agent` with the `ca` option pointing to the additional CA bundle.
  • buildUpdate the system CA store: on Debian/Ubuntu, place the CA cert in `/usr/local/share/ca-certificates/` and run `update-ca-certificates`.
  • buildFor self-signed certs in development only, set `NODE_TLS_REJECT_UNAUTHORIZED=0` but wrap it with environment checks to avoid leaking to production.
  • buildIf using a corporate proxy or VPN, ensure the proxy's certificate is also trusted by Node.js.
( 05 )How to verify

A fix you cannot prove is a guess. Close the loop.

  • verifiedRun `node -e "require('https').get('https://your-host', (res) => console.log('OK'))"` with the corrected trust store and see no error.
  • verifiedUse `openssl verify -CAfile <ca-bundle> <server-cert>` to confirm the certificate chain validates locally.
  • verifiedSet `NODE_DEBUG=tls` and check logs for 'loading CA certificates' messages to confirm your custom CA is loaded.
  • verifiedAfter fix, remove any temporary `NODE_TLS_REJECT_UNAUTHORIZED=0` and ensure the app still works.
  • verifiedTest from a clean container or VM that hasn't had the fix applied to confirm the environment is now trusted.
  • verifiedMonitor production for 24 hours to ensure no recurrence after certificate renewal.
( 06 )Mistakes to avoid

Things that make this bug worse or harder to find.

  • warningSetting `NODE_TLS_REJECT_UNAUTHORIZED=0` in production—this completely disables TLS verification and opens you to MITM attacks.
  • warningConcatenating certificates in the wrong order in the server's cert file: the server cert must come first, then intermediates, then root (though root is often omitted).
  • warningForgetting to restart the Node.js process after changing environment variables or CA bundles.
  • warningAssuming that because cURL works, Node.js will work—cURL may use a different CA store or not enforce some checks.
  • warningOverwriting the default CA bundle with `NODE_EXTRA_CA_CERTS` instead of appending—`NODE_EXTRA_CA_CERTS` adds to the default, so it's safe.
  • warningUsing a self-signed certificate with a hostname mismatch (CN or SAN not matching the server's hostname) which will cause a different error (HOSTNAME_MISMATCH).
( 07 )War story

Production outage: microservice calls fail after SSL certificate renewal

Backend EngineerNode.js 18, Express, axios, internal corporate CA, Docker, AWS ECS

Timeline

  1. 09:15Deploy new version of payment-service to staging.
  2. 09:22Payment-service starts throwing 'UNABLE_TO_VERIFY_LEAF_SIGNATURE' when calling user-service via HTTPS.
  3. 09:25Check user-service logs: no errors, serving traffic fine.
  4. 09:30cURL to user-service from payment-service container works fine.
  5. 09:35Set NODE_TLS_REJECT_UNAUTHORIZED=0 in payment-service env and it works—so it's a TLS issue.
  6. 09:40OpenSSL s_client shows user-service sends only its leaf cert, no chain.
  7. 09:45Check user-service nginx config: ssl_certificate points to only the server cert, not the chain file.
  8. 09:50Fix nginx to include full chain (cat server.crt intermediate.crt > fullchain.crt).
  9. 09:55Reload nginx, remove NODE_TLS_REJECT_UNAUTHORIZED=0. Payment-service works again.

We had just renewed our internal CA's root certificate and reissued all service certificates. The user-service got a fresh cert, but the ops team only replaced the server certificate file, not the full chain. The nginx config had ssl_certificate pointing to a single file that used to contain the chain, but after the renewal, only the leaf cert was placed there. So when Node.js tried to verify, it couldn't find the intermediate CA that signed the leaf, hence 'UNABLE_TO_VERIFY_LEAF_SIGNATURE'.

The first thing I did was check the error message: it said 'UNABLE_TO_VERIFY_LEAF_SIGNATURE', not 'SELF_SIGNED_CERT_IN_CHAIN'. That told me Node.js could not find the issuer of the leaf cert. I ran openssl s_client from the payment-service container to user-service and saw that the server only sent one certificate (the leaf). The issuer field showed our internal CA, but that CA was not in the chain sent by the server. The server expected the client to already have that CA, but Node.js doesn't trust it by default.

I temporarily set NODE_TLS_REJECT_UNAUTHORIZED=0 to confirm the rest of the connection worked. Then I looked at the user-service's nginx configuration. The ssl_certificate directive pointed to /etc/nginx/certs/server.crt. That file contained only the server certificate. I concatenated the server cert, the intermediate cert, and the root CA into one file and updated the config to use that. After reloading nginx and removing the environment variable, the error was gone. The lesson: always serve the full chain, not just the leaf, and verify with openssl s_client -showcerts.

Root cause

User-service nginx was configured to serve only the leaf certificate, missing the intermediate CA in the certificate chain sent during TLS handshake.

The fix

Concatenated server certificate, intermediate CA, and root CA into a single 'fullchain.pem' and updated nginx ssl_certificate to point to that file.

The lesson

Always verify the full certificate chain is served by the server using openssl s_client -showcerts. A browser or cURL might cache intermediates, but Node.js does not.

( 08 )Understanding Node.js TLS Certificate Verification

Node.js uses the OpenSSL library for TLS. The built-in CA store is derived from Mozilla NSS and is compiled into the Node binary. When making an HTTPS request, Node.js verifies the server certificate by building a chain from the leaf to a trusted root. If any certificate in the chain is not found or is invalid, the handshake fails.

The error message is critical: 'UNABLE_TO_VERIFY_LEAF_SIGNATURE' means the leaf certificate's issuer is not found in the chain or the trust store. 'SELF_SIGNED_CERT_IN_CHAIN' means a certificate in the chain (possibly the leaf or an intermediate) is self-signed and not trusted. 'CERT_UNTRUSTED' is more generic. Always inspect the full error message from the 'code' property of the error object.

( 09 )The Role of NODE_EXTRA_CA_CERTS and Custom Agents

The environment variable NODE_EXTRA_CA_CERTS allows you to specify a file containing one or more CA certificates in PEM format. Node.js appends these to the default trust store. This is the safest way to trust internal CAs without disabling verification. However, a common mistake is setting this variable to a file that does not exist or that has incorrect permissions—Node.js silently ignores missing files.

Alternatively, you can create a custom https.Agent with a `ca` option. This overrides the default CA store entirely, so you must include all necessary CAs. If you only include your internal CA and forget the public CAs, requests to external services will fail. The recommended approach is to use NODE_EXTRA_CA_CERTS for additional CAs and leave the default store intact.

( 10 )Server-Side Configuration: Sending the Full Chain

Many TLS errors originate from the server not sending the intermediate certificates. The TLS protocol allows the server to send a list of certificates—the leaf plus any intermediates (the root is usually omitted). If the server only sends the leaf, the client must already have the intermediate in its trust store to validate. This is why cURL might work (it may have the intermediate cached from a previous connection) while Node.js fails.

To fix, configure your web server (nginx, Apache, HAProxy) to serve the full chain. In nginx, the ssl_certificate directive should point to a file containing the concatenation of the server certificate and all intermediate certificates (in order). The ssl_trusted_certificate directive is for OCSP stapling, not for the handshake chain. Use openssl s_client -showcerts to verify what the server actually sends.

( 11 )Debugging with OpenSSL and Node.js Debug Flags

OpenSSL command-line tools are essential: `openssl s_client -connect host:port -showcerts` prints every certificate the server sends. Check if the chain is complete. `openssl verify -CAfile ca-bundle.crt server.pem` validates the chain locally. Also, `openssl x509 -in cert.pem -text -noout` shows details like issuer, subject, validity dates, and SANs.

Node.js has a debug flag for TLS: `NODE_DEBUG=tls node app.js`. This prints verbose logs about loading CA certificates and the handshake. Look for lines like 'loading CA certificates' and 'root cert: ...'. If your custom CA is not listed, the NODE_EXTRA_CA_CERTS path is wrong or the file is malformed.

( 12 )Handling Self-Signed Certificates in Development

In development, it's common to use self-signed certificates. The quick workaround is `NODE_TLS_REJECT_UNAUTHORIZED=0`, but this disables all verification, including hostname checks. A better approach is to add the self-signed certificate to NODE_EXTRA_CA_CERTS. For truly local development, consider using a tool like mkcert that generates a locally trusted CA and certificates, which integrates with the system trust store and Node.js automatically.

If you must use NODE_TLS_REJECT_UNAUTHORIZED=0, wrap it in an environment check: `if (process.env.NODE_ENV === 'development') process.env.NODE_TLS_REJECT_UNAUTHORIZED = '0';`. Never set it in production. Also, be aware that some libraries like axios have their own agent configuration—you may need to set `httpsAgent: new https.Agent({ rejectUnauthorized: false })` in the request options.

Frequently asked questions

Why does cURL work but Node.js fails with the same certificate?

cURL may use a different CA store (e.g., system store vs Node.js built-in). Also, cURL may have cached intermediate certificates from previous connections. Node.js does not cache intermediates; it requires the server to send the full chain. Additionally, cURL's certificate verification can be more lenient in some configurations.

What's the difference between 'UNABLE_TO_VERIFY_LEAF_SIGNATURE' and 'SELF_SIGNED_CERT_IN_CHAIN'?

'UNABLE_TO_VERIFY_LEAF_SIGNATURE' means the leaf certificate's issuer is not found—either the intermediate is missing from the server's chain or not in the trust store. 'SELF_SIGNED_CERT_IN_CHAIN' means a certificate in the chain is self-signed and not trusted; this often happens when the server includes a self-signed root that you haven't added to your trust store.

Should I set NODE_TLS_REJECT_UNAUTHORIZED=0 in production?

Absolutely not. This disables all TLS certificate verification, making your application vulnerable to man-in-the-middle attacks. Use NODE_EXTRA_CA_CERTS to add your internal CA certificates securely. If you're tempted to set this, you have a configuration problem that needs proper fixing.

How do I trust a corporate internal CA in Node.js?

The best way is to set the environment variable NODE_EXTRA_CA_CERTS to a PEM file containing your internal CA certificate. Alternatively, you can add the CA to the system trust store (e.g., on Ubuntu: copy to /usr/local/share/ca-certificates/ and run update-ca-certificates), but Node.js does not read the system store by default—it has its own built-in store. So NODE_EXTRA_CA_CERTS is the reliable method.

My certificate is valid but Node.js says it expired—what's wrong?

Check the system clock on both the server and the Node.js client. If the clock is off by more than a few minutes, TLS verification can fail due to 'not yet valid' or 'expired' errors. Use `date` command to verify. Also, ensure the certificate's validity period is correct. Node.js uses the system time for verification.