anthropics / anthropics/claude-code

[BUG] NODE_OPTIONS=--use-system-ca replaces instead of augments the bundled CA store — confirmed regression #72066 was auto-closed as not_planned

Open
#91,426 0 comments 0 reactions 0 assignees View on GitHub
area:networking bug has repro platform:macos regression
Dominant language
Python
Stars
145k
Forks
23.1k
PR merge metrics
PR metrics pending

Description

### Preflight Checklist

- [x] I have searched [existing issues](https://github.com/anthropics/claude-code/issues?q=is%3Aissue%20state%3Aopen%20label%3Abug) and this hasn't been reported yet
- [x] This is a single bug report (please file separate reports for different bugs)
- [x] I am using the latest version of Claude Code

### What's Wrong?

`NODE_OPTIONS=--use-system-ca` makes Claude Code discard its bundled public root certificates
and trust **only** the OS certificate store. On a managed macOS machine the OS store as read
here contains just the admin/user-added organization certificates and no public roots, so every
endpoint anchored on a public root fails TLS verification and Claude Code cannot start a
conversation.

Claude Code's own debug log shows the whole thing in two lines. Without the flag:

```
CA certs: stores=bundled,system, extraCertsPath=undefined
CA certs: Loaded 121 bundled root certificates
CA certs: Loaded 9 system CA certificates
```

With `NODE_OPTIONS=--use-system-ca`:

```
CA certs: stores=system, extraCertsPath=undefined
CA certs: Loaded 9 system CA certificates
```

**121 bundled roots are dropped**, and the 9 that remain are organization certificates that
cannot anchor any public chain.

This is the regression already confirmed and reproduced by a maintainer in **#72066**, which the
lifecycle sweep closed as `not_planned` about nine hours *before* that confirmation was posted.
The issue was never reopened, no other open issue tracks it, and the behaviour is unchanged in
2.1.252.

I am filing a new issue only because #72066 is closed and its closing comment instructs
reporters to do so. **Please reopen or relink #72066 rather than re-triaging this from scratch**
— the diagnosis there is already correct. Timeline:

| When (UTC) | Event |
|---|---|
| 2026-06-28 | Reported |
| 2026-08-01 | Labeled `stale` by the sweep |
| 2026-08-16 10:10 | Auto-closed as `not_planned` (14 days after the `stale` label) |
| 2026-08-16 18:58 | Maintainer confirms and reproduces on 2.1.233, dates the regression to ~2.1.70, states *"We'll fix Claude Code to treat the flag additively, matching Node."*, and adds the `regression` and `reproduced` labels — but does not reopen |
| 2026-08-16 18:59 | Sweep removes the `stale` label |
| 2026-09-01 | Still reproduces on 2.1.252 |

So the labels read `bug` / `reproduced` / `regression` / `has repro` while the state is
`closed / not_planned`, which keeps it out of `is:open` searches.

**Root cause, unchanged in 2.1.252.** Strings from the shipped binary (identifiers are minified;
comments mine):

```js
// cert store resolution
if (a$e("--use-system-ca") || a$e("--use-openssl-ca")) return ["system"];
return DEFAULT; // DEFAULT = ["bundled", "system"]

// NODE_OPTIONS is parsed as a plain string and token-matched
function a$e(e) {
let n = process.env.NODE_OPTIONS;
if (!n) return !1;
return n.split(/\s+/).includes(e);
}
```

`return ["system"]` builds a **new** array rather than appending to the default, so `"bundled"`
is discarded. I confirmed the identical branch in 2.1.232, 2.1.233, 2.1.238, 2.1.247 and
2.1.252, all with the same `["bundled","system"]` default.

**Bisected: the regression landed in 2.1.101, and Claude Code used to do this correctly.** I
pulled the `darwin-arm64` release binaries and compared the extracted strings (no execution):

| Version | How `--use-system-ca` is handled | Result |
|---|---|---|
| 2.1.69 | referenced only in startup telemetry (`has_use_system_ca`); no certificate logic at all | flag inert → **works** |
| 2.1.70 – 2.1.100 | flag is honoured, and the bundled roots are loaded **as the base** before anything is appended | **additive** → **works** |
| 2.1.101 and later | `CLAUDE_CODE_CERT_STORE` is introduced and the branch becomes `return ["system"]` | **replacement** → **breaks** |

The 2.1.100 binary still contains the original additive intent verbatim:

```js
K.push(...q.rootCertificates);
L(`CA certs: Loaded ${K.length} bundled root certificates as base`);
// ^^^^^^^ as base, then append
```

2.1.101 is the first binary containing the replacing branch, alongside the first appearance of
`CLAUDE_CODE_CERT_STORE` (0 occurrences in 2.1.100, 10 in 2.1.101):

```js
if (v2H("--use-system-ca") || v2H("--use-openssl-ca")) return ["system"];
```

So **the last working version is 2.1.100**, and the regression shipped inside the 2.1.101
changelog entry *"Added OS CA certificate store trust by default, so enterprise TLS proxies work
without extra setup"*. The refactor that introduced the store list dropped the "bundled roots as
base" behaviour that the previous 31 releases had.

Small correction to the assessment in #72066, which dates the regression to ~2.1.70: 2.1.70 is
where the flag *started* being honoured, and it was honoured **additively**. Nothing regressed
there.

This also means the requested fix is a restoration rather than a new behaviour: Claude Code's own
implementation was additive from 2.1.70 through 2.1.100.

**This is not documented behaviour.** On the page linked from #72066
(https://code.claude.com/docs/en/network-config#ca-certificate-store) the strings
`use-system-ca`, `use-openssl-ca` and `NODE_OPTIONS` do not appear at all — zero occurrences
each. What the page does say is the opposite: *"By default, Claude Code trusts **both** its
bundled Mozilla CA certificates **and** your operating system's certificate store"*, and
*"`CLAUDE_CODE_CERT_STORE` … The default is `bundled,system`."* So an environment variable that
silently narrows that to one source is undocumented, and cannot be read as intended policy.

I want to address one likely response up front: #72066 notes that *"you shouldn't need
`--use-system-ca` for Claude Code at all"*, which is true — Claude Code has read the OS store by
default since 2.1.101. But nobody sets this variable *for* Claude Code. It is set for `npm` /
`pnpm`, and `NODE_OPTIONS` is process-global, so Claude Code picks it up from the same shell.
"You don't need it" therefore doesn't help: the user cannot scope it away without knowing that
Claude Code reads `NODE_OPTIONS` at all, which is not documented anywhere. That is what makes
this a bug rather than a configuration mistake.

**Why it is easy to hit.** `NODE_OPTIONS=--use-system-ca` is a reasonable thing to have
exported — it is a documented way to make `npm` / `pnpm` trust an internally-issued
certificate. In my case the line had been in `~/.zshrc` for three days for exactly that reason,
from unrelated work. The user never asks Claude Code for anything: it opts itself into another
runtime's environment variable and then interprets it more aggressively than that runtime does.

### What Should Happen?

`--use-system-ca` should be **additive**, matching Node.js: the OS certificate store on top of
the bundled roots, not instead of them. The resolved store should stay `bundled,system` — i.e.
identical to the default — so the flag becomes a no-op for Claude Code rather than a
connectivity failure.

For reference, this is what Node.js v24.15.0 actually does on the same machine.
`default` is the set actually used for verification:

| Invocation | certs used | bundled certs missing from the used set | Semantics |
|---|---|---|---|
| `node` (no flag) | 145 | 0 | — |
| `node --use-system-ca` | **156** | **0** | **additive** |
| `node --use-openssl-ca` | **0** | **145** | **replacement** |
| `claude` with `NODE_OPTIONS=--use-system-ca` | 9 | all 121 | **replacement** |

Measured with:

```console
$ node --use-system-ca -e 'const t=require("tls");
const d=new Set(t.getCACertificates("default")), b=new Set(t.getCACertificates("bundled"));
let m=0; for (const c of b) if (!d.has(c)) m++;
console.log(`default=${d.size} bundled=${b.size} bundled_missing=${m}`)'
default=156 bundled=145 bundled_missing=0
```

Node treats the two flags **differently**. Claude Code collapses them into one branch and makes
both replacing.

Node docs for the flag: https://nodejs.org/api/cli.html#--use-system-ca
Claude Code docs for the store: https://code.claude.com/docs/en/network-config#ca-certificate-store

### Error Messages/Logs

```shell
$ NODE_OPTIONS=--use-system-ca claude -p 'Reply with just OK'
API Error: Unable to connect to API: SSL certificate verification failed.
Check your proxy or corporate SSL certificates

# --debug-file, with the flag set
[DEBUG] CA certs: stores=system, extraCertsPath=undefined
[DEBUG] CA certs: Loaded 9 system CA certificates
[ERROR] Connection error details: code=UNABLE_TO_GET_ISSUER_CERT_LOCALLY (SSL error), message=unable to get local issuer certificate

# --debug-file, same machine, flag unset
[DEBUG] CA certs: stores=bundled,system, extraCertsPath=undefined
[DEBUG] CA certs: Loaded 121 bundled root certificates
[DEBUG] CA certs: Loaded 9 system CA certificates
```

### Steps to Reproduce

Requires a macOS machine whose OS certificate store contains only admin/user-added
certificates and no public roots — i.e. any MDM-managed corporate Mac. No TLS-inspecting proxy
is needed; I verified with `openssl s_client` and `curl` that the failing path presents the
genuine upstream certificate with `Verify return code: 0 (ok)`.

Note: `--debug -p` does not emit the cert-store lines. Use `--debug-file` to capture them.

1. Confirm Claude Code works normally:

```console
$ claude -p 'Reply with just OK'
OK
```

2. Set the Node flag exactly as one would for `npm` / `pnpm`, and re-run:

```console
$ NODE_OPTIONS=--use-system-ca claude -p 'Reply with just OK'
API Error: Unable to connect to API: SSL certificate verification failed.
Check your proxy or corporate SSL certificates
```

3. Capture the resolved store both ways and compare:

```console
$ NODE_OPTIONS=--use-system-ca claude --debug-file /tmp/with.log -p 'hi' >/dev/null 2>&1
$ claude --debug-file /tmp/without.log -p 'hi' >/dev/null 2>&1
$ grep 'CA certs: \(stores\|Loaded\)' /tmp/with.log /tmp/without.log
/tmp/with.log: CA certs: stores=system, extraCertsPath=undefined
/tmp/with.log: CA certs: Loaded 9 system CA certificates
/tmp/without.log: CA certs: stores=bundled,system, extraCertsPath=undefined
/tmp/without.log: CA certs: Loaded 121 bundled root certificates
/tmp/without.log: CA certs: Loaded 9 system CA certificates
```

The 121 bundled roots are gone in the first case.

4. Confirm the bundled store alone works, i.e. the machine and network are fine — only the
store selection is wrong:

```console
$ NODE_OPTIONS=--use-system-ca CLAUDE_CODE_CERT_STORE=bundled claude -p 'Reply with just OK'
OK
```

Note for anyone arriving from a search engine: removing the line from `~/.zshrc` does **not**
fix an already-running shell — the variable is still exported in that process. Open a new
terminal or `unset NODE_OPTIONS`.

### Claude Model

Not sure / Multiple models

### Is this a regression?

Yes, this worked in a previous version

### Last Working Version

2.1.100

### Claude Code Version

2.1.252 (Claude Code)

### Platform

AWS Bedrock

### Operating System

macOS

### Terminal/Shell

Terminal.app (macOS)

### Additional Information

### Environment

- macOS 26.4.1 (build 25E253), arm64
- Node.js v24.15.0 — used only as the reference implementation in the table above. Claude Code
itself is a compiled binary and does not run on Node, which is part of what makes this
surprising: `NODE_OPTIONS` has no legitimate effect on this process.
- MDM-managed Mac. The system keychain holds organization certificates only.
- No TLS-inspecting proxy on the failing path.

The failure is not specific to a model provider. Anthropic-owned endpoints fail in the same run:

```
Failed to fetch version from https://downloads.claude.ai/... : unable to get local issuer certificate
```

### Two adjacent defects in the same line

Both live in the same `if (a$e("--use-system-ca") || a$e("--use-openssl-ca")) return ["system"]`
line, so I have kept them here rather than opening more issues. **Happy to split either into
its own report if you prefer.**

**(a) `--use-openssl-ca` is aliased to the wrong store.** In Node, `--use-openssl-ca` selects
**OpenSSL's** default certificate store (`SSL_CERT_FILE` / `SSL_CERT_DIR`) — a different
location from the macOS keychains. On this machine it resolves to an empty set (0 certificates,
see the table above). Mapping it to `"system"` silently redirects the user to macOS keychains
they did not ask for. Even after `--use-system-ca` is made additive, this would remain
incorrect. Suggest honouring `SSL_CERT_FILE` / `SSL_CERT_DIR`, or ignoring the flag with a
warning, rather than aliasing it.

**(b) The degradation is silent, and the error message misdirects.** The cert-store code already
warns on bad explicit configuration — the binary contains:

```
certs: unrecognized CLAUDE_CODE_CERT_STORE source '${s}', ignoring
```

But dropping 121 bundled roots because of a `NODE_OPTIONS` token produces no warning at all.
The only user-visible output is:

```
API Error: Unable to connect to API: SSL certificate verification failed.
Check your proxy or corporate SSL certificates
```

That points the user at their proxy and their organization's certificates — i.e. at their IT
department — when the actual cause is that Claude Code discarded its own public root bundle. On
a managed corporate Mac this is an expensive false lead: it cost me most of a day auditing
MDM-deployed certificates, TLS-inspection settings, MDM policy-change timestamps, and older
Claude Code versions before I found the one line in `~/.zshrc`.

Two low-cost mitigations, independent of the additive fix:

1. Warn whenever `NODE_OPTIONS` causes the resolved cert store to differ from the default,
naming the flag and the resulting store list.
2. Include the resolved store list in TLS verification errors, e.g.
`... (CA stores: system; 9 certificates loaded, 121 bundled roots not used)`. The debug log
already has this; surfacing it in the error itself would have ended this investigation
immediately.

### Workarounds (confirmed on 2.1.252)

```bash
CLAUDE_CODE_CERT_STORE=bundled,system claude # keeps organization certs working too
CLAUDE_CODE_CERT_STORE=bundled claude
```

Or scope the Node flag so it never reaches Claude Code, per the workaround in #72066:

```bash
alias node='NODE_OPTIONS=--use-system-ca node'
```

For the original `npm` / `pnpm` need, `NODE_EXTRA_CA_CERTS=/path/to/internal-ca.pem` is a better
fit than `NODE_OPTIONS=--use-system-ca`: it adds the one certificate that is actually required
and has no effect on which store Claude Code selects.

Contributor guide

No contributing guide indexed for this repository

Research direction

Start by reproducing the entry point with NODE_OPTIONS=--use-system-ca and compare the --debug-file CA-cert lines with and without the flag; the report identifies the compiled binary's certificate-store resolution branch but names no source file or test. Trace the implementation of CLAUDE_CODE_CERT_STORE and the NODE_OPTIONS handling, then verify that the flag preserves bundled,system roots and the reproduction succeeds.

Written by the indexing model from the issue text.

Assessment

Tech stack
node.js
Domain
cli, networking, security
Issue type
Bug
Difficulty
4/5
Estimated time
3-5 days
Activity status
Active
Clarity
Mostly clear
Newbie friendliness
45/100

Get new issues in your inbox

A short digest of beginner-friendly GitHub issues.