Working Behind Zscaler · Part 2
Certificates All the Way Down
There is one environment variable that makes every error in this post disappear. Set NODE_TLS_REJECT_UNAUTHORIZED=0 and npm stops complaining; the git, pip, and az equivalents are a search away, and every one of them works — in the narrow, literal sense that the command which was failing now completes. I’m going to tell you what they are, in a moment, and then spend the rest of the post asking you not to use them, and that’s a hollow request if I pretend they don’t work. They do. The first time Zscaler broke a tool for me — npm install, 2017, the error a wall of red I’d never seen — I reached for exactly this kind of switch, strict-ssl=false, pasted it into my .npmrc on the strength of the first Stack Overflow answer, watched the install go green, and got on with my afternoon. It was the wrong fix. It cost me nothing that day and a little more every day after, and it’s the reason this post exists. Because turning the check off isn’t repairing the lock — it’s unscrewing the deadbolt because the key sticks. The door opens now, sure. You’ve also thrown away the one thing that would have told you when a real stranger, not your employer’s proxy, started answering the door. So we’re going to do it the other way: teach each tool, one at a time, to trust the corporate root honestly. It’s slower than flipping the switch. It’s the difference between owning the key and removing the lock.
If you’ve landed here straight from a search box with one specific tool on fire, here’s the model in a paragraph, so the fixes read as reasoning instead of spells you paste. Your company routes your HTTPS through Zscaler, which decrypts it, inspects it, and re-encrypts it under a certificate it signs itself — a real man-in-the-middle, running with IT’s blessing. To the browser this is invisible, because the Zscaler root that signs those certificates was pushed into your operating system’s trust store — the machine’s master list of certificate authorities it will believe — before you ever logged in. The browser reads that list, finds a signature it trusts, shows a padlock. Your development tools break because almost none of them read that list. Node, Python, Git, Go, the .NET runtime on Linux — each ships its own bundled roster of trusted authorities, and to every one of them the Zscaler signature is a stranger’s. That’s the whole disease; Part 1 is its full history if you want it. This post is the treatment.
And the treatment has a shape — the single most useful thing to carry out of here. Strip off the tool-specific dialects and every fix behind Zscaler is one of three moves. Every section below is one of them, wearing one tool’s costume.
[!NOTE] The three moves. Whatever the tool, whatever the error string, the fix is one of these:
- Point it at the OS trust store — tell the tool to stop reading its private bundle and use the system list IT already fixed.
- Hand it the corporate root directly — give the tool its own copy of the Zscaler root as a
.pem, through a config setting or an environment variable it reads.- Set the proxy environment variables — when the trouble is reaching the proxy rather than trusting its cert, point the tool at the proxy and let it out.
Before you touch a single config file, though, spend thirty seconds finding out which of the three you’re looking at — because behind Zscaler “the certificate is failing” hides more than one problem, and each move fixes a different one. There’s a ladder for this. Does the site load in your browser? Then the OS trust store is sound — IT did their half, and move 1, pointing a tool at that store, is on the table. Does curl https://registry.npmjs.org succeed from the same shell the tool runs in? Then the system bundle is reachable too, and whatever’s still failing is a tool reading its own private list — move 2’s territory. Does the tool fail where curl just succeeded? You’ve cornered it: the break is that one tool’s bundle or its proxy config, not the machine. Each rung crosses a suspect off the list, and where you fall off names the move you need.
One command underwrites the whole ladder — the same one Part 1 leaned on, promoted here from party trick to standing habit. It shows you whose certificate you’re actually being handed:
openssl s_client -connect registry.npmjs.org:443 -showcerts </dev/null
Read the issuer — the i: line — of the last certificate in the chain it prints. On an unmanaged machine that chain ends in a public authority; on your corporate laptop it ends in something with Zscaler in the name. That’s interception, confirmed in plain text rather than assumed. And it’s not only a diagnosis: -showcerts just printed every certificate in the chain, which means the exact root you need to start trusting is already sitting in that output, waiting to be lifted out — which is the next step, and the one everybody fumbles.
Get the cert that’s actually inspecting you
Here is where people quietly go wrong: they search “Zscaler root certificate,” download a .pem off some forum, install it, and nothing changes. The reason is that plenty of organizations don’t deploy Zscaler’s stock root — they run a custom intermediate CA of their own, signed by an internal root, and the certificate inspecting you is one only your machine has ever seen. A generic download won’t match it. So the rule that saves the afternoon is: never fetch the cert from the internet — export the one your own machine was handed. Four ways to do that, cleanest first.
1. Pull it live with openssl — cross-platform, always exact. You already ran the command; this time keep the output. The last certificate block in the printed chain — from the final -----BEGIN CERTIFICATE----- through its matching -----END CERTIFICATE----- — is the root. Copy it, header lines included, into a file:
openssl s_client -connect registry.npmjs.org:443 -showcerts </dev/null 2>/dev/null > chain.txt
# then copy the last BEGIN…END block from chain.txt into zscaler-root.pem
It is correct by construction: it’s literally the certificate your handshake just used, not a lookalike from the web.
2. Export it from the OS trust store. IT already installed it there for the browser; you can read it back out. The command is one of the few things that genuinely differs by OS:
# macOS
security find-certificate -a -c "Zscaler" -p /Library/Keychains/System.keychain > zscaler-root.pem
# Windows (PowerShell) — yields DER; add -Type below or export as Base-64 X.509 from certmgr.msc for PEM
Get-ChildItem Cert:\LocalMachine\Root | Where-Object { $_.Subject -match 'Zscaler' } | Export-Certificate -FilePath zscaler-root.cer
# Linux — it's already a file on disk; just find it
ls /usr/local/share/ca-certificates/ # Debian/Ubuntu
ls /etc/pki/ca-trust/source/anchors/ # RHEL/Fedora
3. Export it from the browser padlock. The GUI fallback that works anywhere and needs no shell: click the padlock → certificate details → view the chain → select the top/root certificate → export as Base-64 (PEM). Slower, but it’s there when nothing else is.
4. Ask IT for the PEM. The unglamorous option, and often the fastest correct one — the only clean answer when there’s a custom intermediate you can’t fully reconstruct yourself. There’s no shame in it; it’s frequently the right first move, not the last resort.
[!NOTE] You generally want the root, not the intermediate: the intermediate is presented to your tools during the handshake, but it’s the root you install as permanently trusted. If you can’t tell which is which, grab the whole chain — trusting an extra certificate you already trust costs nothing, and a missing root costs you the afternoon.
Install it once into the OS trust store
On your managed laptop this move is, strictly, already done — MDM pushed the Zscaler root into the OS store before you got the machine, which is exactly why the browser works and why a handful of tools already do. So why learn to do it yourself? Two reasons. First, not every machine you touch was blessed by MDM: a personal Linux box, a hand-rolled VM, and — the entire subject of the next post — every container and WSL distro starts life with an OS store that has never heard of your employer. Second, installing the root is what produces the system certificate bundle, a single file on disk, and that file is the thing you’ll aim half the tools below at. So it’s worth knowing cold. Full commands, per OS:
# macOS — trust it system-wide; system bundle lives at /etc/ssl/cert.pem
sudo security add-trusted-cert -d -r trustRoot -k /Library/Keychains/System.keychain zscaler-root.pem
# Windows (run as admin) — store is Cert:\LocalMachine\Root
certutil -addstore -f "ROOT" zscaler-root.cer
# Linux, Debian/Ubuntu — file MUST end in .crt and be PEM; bundle: /etc/ssl/certs/ca-certificates.crt
sudo cp zscaler-root.pem /usr/local/share/ca-certificates/zscaler-root.crt
sudo update-ca-certificates
# Linux, RHEL/Fedora — bundle: /etc/pki/tls/certs/ca-bundle.crt
sudo cp zscaler-root.pem /etc/pki/ca-trust/source/anchors/zscaler-root.crt
sudo update-ca-trust
This is the highest-leverage action in the post, and on Windows it’s nearly the whole game: everything built on schannel — the .NET runtime, PowerShell, Git if you point it there — starts trusting Zscaler the instant the root lands in the store, with no further per-tool fiddling. And then there’s everything else: the long list of tools that will look straight at the store you just fixed, ignore it, and keep failing — because they brought their own. That list is the rest of this post.
The tools that brought their own
What follows is the same two-step every time — find where the tool keeps its trust, then apply move 1, 2, or 3 — so once the rhythm clicks you can stop reading and pattern-match the rest yourself. For each: the error you’ll see, where its bundle lives, and the shortest honest fix.
curl and wget — the probe, and the exception that teaches the rule
You already used curl to climb the ladder, which makes it the natural first tool — and it’s also the one that most cleanly shows why “did you install the cert?” is the wrong question to ask a machine. On macOS the system curl reads the Keychain, so after Move 1 it just works; but the curl you installed from Homebrew is built against OpenSSL and reads a bundle file instead, so the same command succeeds in one shell and fails in another on one machine. When it fails, hand it the root explicitly — move 2:
curl --cacert /path/to/zscaler-root.pem https://registry.npmjs.org
export CURL_CA_BUNDLE=/path/to/zscaler-root.pem # once, for the whole shell
wget is the same story in a different dialect: --ca-certificate=/path/to/zscaler-root.pem.
git — one flag on Windows, one path everywhere else
Git’s failure is the one you’ll know on sight: fatal: unable to access '…': SSL certificate problem: unable to get local issuer certificate. Git ships its own CA file, so move 2 applies — point it at one holding the Zscaler root:
git config --global http.sslCAInfo /path/to/zscaler-root.pem
On Windows there’s a cleaner move that skips the bundle-juggling entirely: tell Git to use schannel and it reads the Windows store you already fixed in Move 1 — so this is move 1, not move 2:
git config --global http.sslBackend schannel
Resist the sibling that tops every forum thread, git config --global http.sslVerify false. That’s the deadbolt from the opening, wearing Git’s clothes; we’ll come back to why it costs more than it saves.
Node, npm, pnpm, yarn — the family that started it all
This is the corner that drew first blood in 2017, and the fix I should have reached for then instead of strict-ssl=false is a single environment variable — the honest cousin of the switch this post opened with:
export NODE_EXTRA_CA_CERTS=/path/to/zscaler-root.pem
The word extra is the whole point: it appends the Zscaler root to Node’s built-in list rather than replacing it, so you keep every public CA and add the one your proxy needs. Because pnpm and Yarn both run on Node, they inherit this for free. npm occasionally wants telling separately for its own registry traffic — npm config set cafile /path/to/zscaler-root.pem — and Yarn Berry keeps its own key, httpsCaFilePath, in .yarnrc.yml. But NODE_EXTRA_CA_CERTS is the load-bearing one: set it in your shell profile and most of the ecosystem falls into line.
Python and pip — the env var that fixes half your tools at once
pip carries its own cert setting — pip config set global.cert /path/to/zscaler-root.pem, or PIP_CERT for a one-off — but the more valuable knob sits one level down. The requests library, and a large share of the Python tools you run day to day, trusts a bundle called certifi, and you can redirect it wholesale:
export REQUESTS_CA_BUNDLE=/path/to/zscaler-root.pem
export SSL_CERT_FILE=/path/to/zscaler-root.pem # broader — many OpenSSL-based tools honor this one
You can find the bundle Python is actually using with python -m certifi, and you will be tempted to just append the Zscaler root to that file. Don’t lean on it: the next pip install --upgrade certifi ships a fresh bundle and silently erases your edit, and you’ll re-derive the whole problem months later having forgotten you ever solved it. An env var you own survives the upgrade; a file the package manager owns does not. Hold onto that distinction — it’s about to become the entire point of the next section.
.NET and NuGet — mostly Move 1, with a platform twist
Good news for the Windows-heavy reader: on Windows the .NET runtime and NuGet ride on schannel, so they read the OS root store, which means they just work the moment you finish Move 1 — no per-tool config at all. On Linux and macOS the same runtime is built on OpenSSL instead, so it wants the root present in the system bundle — which is exactly what update-ca-certificates / update-ca-trust gave you back in Move 1. NuGet’s own proxy settings, if you need them, live in nuget.config. One disambiguation that saves a wasted hour: dotnet dev-certs https has nothing to do with any of this — it’s the self-signed cert for localhost development, a different animal entirely. Don’t go chasing a Zscaler problem in there.
That Windows “it just works,” though, is exactly what makes the next tool so maddening — because it comes from the same vendor, in the same install, and does the precise opposite.
Azure CLI — the tool that ignores everything you just did
This is the one that cost me the most, and the one that proves the whole thesis. You add the Zscaler cert to the Windows root store. The browser works. PowerShell’s Az module works. And az login still fails — SSLError(... 'certificate verify failed') — on the same machine, seconds later. The instinct is that you installed the cert wrong. You didn’t. az is Python under the hood, and Python reads certifi, not the OS store — even on Windows. The store you carefully fixed is one az was never going to consult.
I didn’t have to reverse-engineer that, and the way I found out is the point. Microsoft documents it, in an official troubleshooting page headed “Work behind a proxy” — and the remarkable thing is what the page contains: a table of filesystem paths to the Azure CLI’s own private copy of certifi’s cacert.pem, one row per operating system. A few of the entries:
# macOS, Apple Silicon (Intel models swap /opt/homebrew for /usr/local):
/opt/homebrew/Cellar/azure-cli/<version>/libexec/lib/python<version>/site-packages/certifi/cacert.pem
# Windows, 64-bit:
C:\Program Files\Microsoft SDKs\Azure\CLI2\Lib\site-packages\certifi\cacert.pem
# Linux, Debian/Ubuntu:
/opt/az/lib/python<version>/site-packages/certifi/cacert.pem
Read a couple of those. To settle who one Microsoft command trusts, the answer lives down inside the CLI’s own bundled copy of Python, in a certifi package, at a cacert.pem that has nothing to do with the system trust store — and on macOS its location depends on which CPU you bought. The thesis of this series isn’t a metaphor here. It’s a table of filepaths, printed in the vendor’s own documentation.
The doc’s fix is move 2, and it offers two ways to do it: append your proxy’s certificate to that cacert.pem, or copy the bundle’s contents to a file of your own and append there — then point REQUESTS_CA_BUNDLE at whichever file you chose.
export REQUESTS_CA_BUNDLE=/path/to/combined-bundle.pem
Take the second option. The first works today and breaks on a schedule: that path has a <version> in it, so the next az upgrade ships a fresh cacert.pem, quietly erases your appended cert, and you hit the identical error months later having forgotten you ever solved it. A bundle you own survives the upgrade; the tool’s does not — which is exactly why the doc bothers to offer the copy-it-out variant. That’s the rule the Python section promised, now with a filepath attached: append to the tool’s own bundle and it’s fixed until the next upgrade; point the env var at a file you control and it’s fixed for good. (On Linux you can often skip the copying: /etc/ssl/certs/ca-certificates.crt already carries the public roots plus your Zscaler cert after Move 1, so just aim REQUESTS_CA_BUNDLE there.)
And then the payoff — the reason az earns a whole section. Look at what one vendor’s toolbox does with a single certificate:
| Tool | Built on | Trust it reads | How the root gets in |
|---|---|---|---|
az |
Python | requests / certifi |
REQUESTS_CA_BUNDLE |
Az (PowerShell) |
.NET | schannel / OS store | Move 1 — nothing extra |
azcopy |
Go | system roots | Move 1 — nothing extra |
func (Functions Core Tools) |
Node | Node’s bundled roots | NODE_EXTRA_CA_CERTS |
Same company, same certificate, four completely different ways to install it — because each tool is built on a different runtime, and each runtime brought its own idea of who to trust. This is the whole series in one table, and if you run Azure Functions and DevOps it’s also just your Tuesday. I’ll be straight about the evidence, though: the az scar is mine. The other three I’m showing you as documented divergence, not four war wounds — I don’t lean on azcopy or func hard enough to have personally bled on them. But the pattern is real and demonstrable, and it’s the cleanest proof that “every tool ships its own trust store” was never an abstraction.
Move 3: the proxy variables, and when you don’t need them
The third move is a different animal from the first two — it has nothing to do with trust and everything to do with reachability. Sometimes the certificate is fine and the tool simply can’t find its way out of the building. The standard set:
export HTTP_PROXY=http://proxy.corp:8080
export HTTPS_PROXY=http://proxy.corp:8080
export NO_PROXY=localhost,127.0.0.1,.internal.corp
Two gotchas cost the most time. First, casing: some tools read only the lowercase names, some only the uppercase — so set both HTTP_PROXY and http_proxy and stop guessing. Second, NO_PROXY is what keeps your internal hosts from being dragged out through the proxy and failing; list your corporate domains there. And if you hit 407 Proxy Authentication Required, the proxy wants credentials — often NTLM or Kerberos — and the usual answer is a local auth-proxy shim like px or cntlm that handles the challenge and hands your tools a plain, unauthenticated proxy to talk to.
But the highest-value thing in this section is a subtraction. If your org runs the Zscaler App in tunnel mode — many do — traffic is intercepted transparently, at the network layer, before any tool’s proxy setting enters into it. In that setup you may not need HTTP_PROXY at all, and setting it can make things worse. The certificate is the problem, not the proxy variable. So before you go spelunking through proxy configs, check whether you needed them at all: a great many “proxy” errors behind Zscaler are trust errors wearing a disguise.
The rest, at a glance
By now the pattern should be doing the work for you. Here’s a sweep of the remaining usual suspects — and the tell that you’ve actually internalized the post is that you can predict each fix before you read it, because every one is just move 1 or move 2 under a new name:
| Tool | Move | The knob |
|---|---|---|
| Go | 1 | Reads system roots — works after Move 1. Add GOPROXY=direct (or an internal mirror) if the module proxy is blocked, GOSUMDB=off if the checksum database is unreachable. |
| Rust / cargo | 2 | CARGO_HTTP_CAINFO=/path/to/zscaler-root.pem |
| Yarn Berry | 2 | httpsCaFilePath: /path/to/zscaler-root.pem in .yarnrc.yml |
| Java / JVM | 2 | Import into the runtime’s keystore: keytool -importcert -cacerts -file zscaler-root.pem |
The one move to never make
I promised you a shortcut at the top of this post and then spent two thousand words walking past it. It exists, in a dialect for every tool: NODE_TLS_REJECT_UNAUTHORIZED=0 for Node, strict-ssl false for npm, http.sslVerify false (or GIT_SSL_NO_VERIFY) for git, disabling the cert for pip, -k for curl, AZURE_CLI_DISABLE_CONNECTION_VERIFICATION=1 for az. I’m deliberately not laying these out as a tidy, runnable block — because a tidy block is precisely how the wrong fix spreads. Someone skims a page like this one, grabs the nearest code, ships it, and moves on. Don’t be the skim.
[!WARNING] Every one of those settings turns certificate verification off. They all “work,” in the narrow sense that the failing command completes — and every one is the deadbolt taken back off the door. Reach for them and you don’t just wave Zscaler through; you wave everyone through.
“It’s insecure” is too vague to move anyone, so here’s the precise cost. The entire job of certificate checking is to tell your employer’s sanctioned man-in-the-middle apart from an unsanctioned one — the café Wi-Fi quietly proxying you, the compromised router, the actual attacker. Switch verification off and those two become the same event: you’ve unplugged the one instrument that would have caught the genuine intrusion, and kept the one that never threatens you. On top of that, corporate security tooling increasingly flags these very settings, so turning them on can start a conversation you’d rather not have — and they routinely don’t even fully work, because some other tool in the chain ignores the override and fails anyway, leaving you the insecurity without the convenience. The quietest cost is the worst: every silenced error trains you to treat a certificate failure as noise to be muted rather than a question to be answered, until the day it’s a real one and you mute that too.
The honest fixes in this post cost an extra ten minutes, and they buy back a machine that still knows the difference between your proxy and an enemy. That trade isn’t close.
What you actually learned
Step back and count it, because it’s less than it felt like going in. Not a dozen tool-specific incantations — three moves, and the judgment to tell which one a given failure needs. Point it at the OS store; hand it the root; set the proxy vars. Every section above, az’s buried cacert.pem included, was one of those three wearing a costume. Internalize the pattern and the next unfamiliar tool that breaks behind Zscaler stops being a new problem: it’s a puzzle you’ve already solved, waiting only for you to ask where it keeps its trust.
Everything here, though, quietly assumed one thing — that you were fixing your machine. Every fix landed in your shell profile, your OS store, your .pem on your disk. But more and more of the code you run never touches that machine’s trust at all: it runs inside a container built fresh in CI, or a WSL distro that looks like your laptop and isn’t, or a base image that has never in its life heard of your employer. Each is a brand-new machine that never got the memo, and your carefully fixed host does precisely nothing for it. That’s the next post — the rooms this fix can’t reach.
This series has no talk or book beneath it — it’s the memo I never got, reconstructed the slow way across a decade of the same certificate breaking the same handshake in a new tool each time. The az section leans on Microsoft’s own troubleshooting docs — the vendor tabulating the filesystem path to its own tool’s private trust bundle is the whole thesis in the plainest possible form. The scars are first-hand: strict-ssl=false in 2017, and the Homebrew cacert.pem that az upgrades kept wiping out from under me. Every command here was run against public hosts; nothing is specific to one employer’s tenant.
⟓⤬⟔ Kalaripayattu — the martial art of Kerala, among the oldest still practised, drilled as fixed sequences called meypayattu and repeated until the body answers a threat before the mind can name it.