Troubleshooting · TLS

unable to get local issuer certificate — the error that fails on the first try and never retries

30-SECOND VERSION
  1. Instant, every-time failure means the fault is local. Almost everything else is retried with backoff; certificate validation is one of the few that gets none.
  2. Export your network's CA certificate, point NODE_EXTRA_CA_CERTS at it, then restart the client.
  3. Do not set NODE_TLS_REJECT_UNAUTHORIZED=0 — it disables TLS verification process-wide, trading a config problem for a security one.

Almost everything that can go wrong between your client and an endpoint is retried for you — server errors, overload, request timeouts, dropped connections and temporary throttles all get up to 10 attempts with exponential backoff before anything reaches your screen. TLS certificate validation failure is one of the few that gets none. It is reported on the first attempt. That inverts how you should read it: an error that appears instantly and every time is not evidence the service is unstable — it is evidence the failure is local, deterministic, and yours to fix.

What you're seeing

any of these
Error: unable to get local issuer certificate

Error: self-signed certificate in certificate chain

{ code: 'SELF_SIGNED_CERT_IN_CHAIN' }
{ code: 'UNABLE_TO_VERIFY_LEAF_SIGNATURE' }

SSL certificate verification failed
SSL certificate error (...)

These are the same event described at four different heights. Your runtime took the certificate the server presented, tried to build a chain up to a root it already trusts, and couldn't finish. The message names where the walk stopped.

CodeMessage you'll seeWhere the chain broke
UNABLE_TO_GET_ISSUER_CERT_LOCALLYunable to get local issuer certificateThe issuer of the presented certificate is not in your trust store
SELF_SIGNED_CERT_IN_CHAINself-signed certificate in certificate chainSomething in the chain signed itself — typically a proxy's own root
UNABLE_TO_VERIFY_LEAF_SIGNATUREunable to verify the first certificateThe server sent the leaf but not the intermediates
CERT_HAS_EXPIREDcertificate has expiredDates, not trust
Older OpenSSL builds print self signed certificate in certificate chainno hyphen. Same failure. Search for both spellings.

None of these means the server is down. A server that is down does not get far enough to present a certificate.

The asymmetry that makes this error look worse than it is

FailureRetry budgetWhat you actually experience
Server errors, overload, request timeout before any response has streamedup to 10 attempts, exponential backoffa long pause, then usually success — you often never learn it happened
Dropped connection, or a connection your machine broke by going to sleepsame budgetsame
Temporary 429 throttlesame budgetsame
Stalled response stream, nothing arrived yetone extra re-issue, outside the budgetsame
TLS certificate validation failurenone — reported on the first attemptinstant, every time, no pause
TLS handshake timeoutstill retried — it's transient, not a validation failurea pause, then usually success

Read the table as a filter on what reaches your eyes. Anything with a retry budget has to fail ten times in a row, with growing gaps between attempts, before it surfaces. Anything with no budget surfaces on attempt one. So the errors that arrive fastest are systematically the ones nobody retries — which means a large share of failures people describe as "this service is flaky" are the opposite: a stable, local, reproducible misconfiguration that simply had nothing standing between it and the terminal.

The practical version: an error that takes 30 seconds to appear and an error that appears instantly are not the same class of problem, even when the text looks similar. Time-to-error is diagnostic information. Use it before you change anything.

Two honest caveats. Certificate failures were not always exempt from retries — older client versions ran them through the full budget first, so on an older build a certificate problem does look slow and flaky. And "TLS error" is not the same as "certificate error": a handshake that times out is transient and still retried. Confirm the behaviour on the client version you're actually running before relying on timing as a signal.

Which layer is actually broken

LayerWhat's happeningHow to recognise itWho fixes it
Your runtime's trust storeThe CA is installed in the OS, but the runtime can't read the OS storeAn npm install on an older Node; a native install of the same client worksYou
A TLS-inspecting proxySomething terminates TLS and re-signs traffic with a private rootThe presented issuer is not a public CAYou (trust the CA) or your IT team
The CA bundle never reached the processNODE_EXTRA_CA_CERTS is set but the file wasn't loadedConfig screens show the path; the debug log has no "appended" lineYou
Missing intermediatesThe server sends the leaf onlyUNABLE_TO_VERIFY_LEAF_SIGNATURE, and other clients on other machines fail identicallyThe endpoint operator
The endpoint's certificate expiredDates, not trustIssuer is a public CA, and notAfter is in the pastThe endpoint operator

Most write-ups on this error jump straight to "add the certificate." That works for rows two and three and does nothing at all for rows one, four, and five. The next section is ordered so you find out which row you're in before you edit anything.

Check in this order

1 · Look at the certificate that is actually being presented#

This is the single most informative command on the page. Run it before forming a theory.

terminal
HOST=api.9coding.com
openssl s_client -connect "$HOST:443" -showcerts </dev/null 2>/dev/null \
  | openssl x509 -noout -issuer -subject -dates

Read the issuer line:

  • A public CA you recognise → nothing is intercepting you. Check the dates; if notAfter is in the past, this is the operator's problem, not yours.
  • Your company's name, your security vendor's name, or a hostname on your own network → your traffic is being terminated and re-signed in transit. That is the whole cause. Go to step 3.
  • The command hangs or returns nothing → you have a connectivity problem, not a certificate problem. See connection errors.

2 · Confirm your runtime can read the OS trust store#

Clients built on Node trust their own bundled CA set and the operating system's store — but reading the OS store requires a runtime new enough to support it. When it isn't, a certificate your IT team correctly installed system-wide is invisible to the client, and everything else on the machine keeps working. Browsers work. curl works. Only the client fails.

terminal
node -p "process.version"
node -p "typeof require('tls').getCACertificates"   # "function" = can read the OS store

If that prints undefined, the OS store is not in play and only the bundled set plus NODE_EXTRA_CA_CERTS apply. Either upgrade the runtime or continue to step 3 — step 3 works in both cases.

3 · Export your organisation's CA and point the client at it#

macOS
security find-certificate -a -p /Library/Keychains/System.keychain > ~/corp-ca.pem
security find-certificate -a -p /System/Library/Keychains/SystemRootCertificates.keychain >> ~/corp-ca.pem
Windows · PowerShell
Get-ChildItem Cert:\LocalMachine\Root, Cert:\LocalMachine\CA |
  ForEach-Object {
    "-----BEGIN CERTIFICATE-----"
    [Convert]::ToBase64String($_.RawData, 'InsertLineBreaks')
    "-----END CERTIFICATE-----"
  } | Set-Content -Encoding ascii $HOME\corp-ca.pem

Linux — install the root properly, then use the system bundle:

Linux · terminal
sudo cp corp-root.crt /usr/local/share/ca-certificates/corp-root.crt   # Debian/Ubuntu
sudo update-ca-certificates
# RHEL/Fedora: /etc/pki/ca-trust/source/anchors/ then `sudo update-ca-trust`

Then point the variable at the bundle and restart the client — these variables are read once at startup, and a running process keeps the environment it launched with:

terminal
export NODE_EXTRA_CA_CERTS=/etc/ssl/certs/ca-certificates.crt   # Debian/Ubuntu
# macOS/Windows: the file you just wrote

Verify the bundle actually satisfies the chain before you blame anything else:

terminal
openssl s_client -connect "$HOST:443" -CAfile "$NODE_EXTRA_CA_CERTS" </dev/null 2>&1 \
  | grep -i 'verify return code'
# want: Verify return code: 0 (ok)

4 · Confirm the bundle was loaded, not just configured#

These are different facts, and this is where most "I already set that" conversations end. A status screen that shows the path is telling you the variable is set. It is not telling you the file was read — an unreadable path, a wrong permission, or a PEM with the wrong line endings all produce a configured-but-not-loaded state that looks identical from the outside.

Start the client with debug logging and look for the line that names the file:

terminal
claude --debug
# then read the newest file under ~/.claude/debug/

A line confirming extra certificates were appended from NODE_EXTRA_CA_CERTS, naming your path, means it loaded. A "failed to read" or "failed to load" line gives you the reason. No line at all is a failure too — it means the variable never reached the process, which happens routinely when it was exported in one shell and the client was started from another, or when the client runs under a supervisor or background agent that never saw your shell. Put it in the client's own settings file rather than a shell profile if that's your situation.

5 · Take the client out of the picture — carefully#

terminal
# <model-id>: copy an id from https://api.9coding.com/v1/models
curl -sS -w '\n%{http_code}\n' https://api.9coding.com/v1/messages \
  -H "Authorization: Bearer $ANTHROPIC_AUTH_TOKEN" \
  -H "content-type: application/json" \
  -H "anthropic-version: 2023-06-01" \
  -d '{"model":"<model-id>","max_tokens":16,"messages":[{"role":"user","content":"hi"}]}'
terminal
node -e 'require("https").get("https://api.9coding.com/", r => console.log(r.statusCode))
  .on("error", e => console.error(e.code, "-", e.message))'

Run both, because they do not read the same trust store, and the difference between them is the answer:

  • curl succeeds, the Node one-liner fails → the certificate is fine system-wide and the problem is the runtime's trust store. Back to steps 2 and 3. This is the most common outcome, and it is exactly why "but my browser works" is not evidence of anything.
  • Both fail with a certificate error → nothing on this machine trusts that chain. Step 1 told you why.
  • Both succeed, the client still fails → the client isn't reading the environment you edited. Step 4, last paragraph.

The variable you'll be tempted to set, and shouldn't

Every thread about this error eventually surfaces NODE_TLS_REJECT_UNAUTHORIZED=0. It will make the error go away. Here is what it actually does.

It disables TLS certificate verification for the entire process — not for one host, not for one request. Every outbound HTTPS connection that process makes, including ones you didn't think about, will now accept any certificate from anyone able to sit between you and the destination. On a machine where traffic is already being intercepted, that is precisely the property you were relying on to notice.

You had a configuration problem, solvable in step 3 in about two minutes. This trades it for a security problem that is silent, permanent until someone notices, and travels with any script or image you copy the setting into. If you set it once during a five-minute experiment, unset it in the same session.

There is no version of this that is fine "just for testing on an internal endpoint." Trusting a specific CA is the same amount of work and leaves verification switched on for everything else.

When it isn't your problem

You are looking at the operator's certificate, not your setup, when:

  • step 1 shows an issuer that is a public CA, and notAfter has passed,
  • or step 1 shows a leaf with no intermediates and curl fails the same way,
  • and the same failure reproduces from a different machine on a different network — a phone hotspot is enough,
  • and it started without any change on your side.

Certificate expiry is a scheduled event that someone missed, so it usually resolves in hours rather than minutes. Check the operator's status page before you spend the afternoon regenerating trust stores — ours is at status.

When reporting it, include the request id — 9Coding error responses carry one in the form (request id: 2026...). That id plus the timestamp, the model name and the full error text lets the exact call be traced. A report that only says it doesn't work cannot be investigated.

Certificate errors and connection errors are not the same problem

A certificate error means the connection succeeded and then verification failed — you got far enough to be handed a certificate. ECONNREFUSED, ETIMEDOUT, and fetch failed mean you never got there, and no amount of CA configuration will change them. They also retry, which is why they feel slow and this one feels instant. See connection errors.

Related