TLS & security

TLS interception and corporate root CAs

How intercepting proxies forge certificates, what that breaks, and the exact trust store fix for curl, Python, Node, Go, Java, npm, pip and Git.

· 18 min read · How we verify this

Key points

  • An intercepting proxy mints a leaf certificate per hostname, signed by a CA installed in the client trust store; TLS is not broken, a trust anchor was simply added.
  • Every runtime has its own trust store: OS-level trust does not reach Java, Python's certifi, Node.js or Go on macOS and Windows, which is why one machine can have curl working and pip failing.
  • unable to get local issuer certificate, CERTIFICATE_VERIFY_FAILED and x509: certificate signed by unknown authority are one fault in three languages: the CA is missing from that store.
  • Interception silently replaces the client's cipher and version negotiation with the proxy's own, and it cannot forward client certificates, so mTLS destinations must be excluded from decryption.

An intercepting proxy performs a deliberate machine-in-the-middle on TLS. It accepts the client's connection, reads the intended hostname (from the CONNECT request, or from SNI when the traffic is transparently redirected), opens its own TLS session to the real server, and then generates a leaf certificate for that hostname on the fly, signed by a CA whose root has been installed into the client's trust store. The client validates that forged certificate successfully, because from its point of view the chain is legitimate. Nothing in TLS is broken. A trust anchor was added, and the anchor's holder can now impersonate every site on the internet to that client.

The mechanism is the same one described in transparent and intercepting proxies, applied to encrypted traffic. What follows is what it enables, what it costs, and the trust store fixes developers actually need.

The handshake, step by step#

text
1. Client -> proxy    CONNECT api.example.com:443 HTTP/1.1
                      (or a transparent redirect; the proxy reads SNI instead)
2. Proxy  -> client   HTTP/1.1 200 Connection Established
3. Client -> proxy    ClientHello, SNI = api.example.com
4. Proxy  -> server   its own ClientHello (proxy's version and cipher preferences)
5. Server -> proxy    real certificate for api.example.com
6. Proxy              validates the real chain, then mints:
                        Subject:  CN=api.example.com
                        SAN:      DNS:api.example.com  (copied from the real cert)
                        Issuer:   CN=Acme Corp TLS Inspection CA
                        Validity: often days, not months
                        no SCTs, usually a fresh key or a shared per-proxy key
7. Proxy  -> client   forged certificate, signed by the corporate CA
8. Client             validates against its trust store, finds the corporate root, succeeds

Two independent TLS sessions now exist, and the proxy holds the plaintext of both. Step 6 is where the properties diverge from the real connection: the forged certificate is not in any Certificate Transparency log, its validity period is usually short, and its extensions are a subset of the original.

What interception buys, and what it costs#

CapabilityEnabled by decryptionNotes
Data loss prevention on uploadsYesThe primary business justification; cannot be done on ciphertext
Malware scanning of downloadsYesAlso the reason large file scanning adds latency
URL and content policy beyond hostnameYesWithout decryption, policy is limited to SNI, and SNI is not an authorisation boundary
Detecting exfiltration to allowed hostsYesThe case SNI filtering explicitly cannot cover
Full request logging for auditYesWhich is also the privacy cost
CostMechanism
Certificate pinning breaksThe pinned key is not in the forged chain
Client certificate auth breaksThe proxy has no access to the client's private key
Crypto strength becomes the proxy's, not the client'sThe client can only see the proxy leg
Certificate Transparency enforcement is bypassedLocally installed roots are exempt from CT requirements by design
The interception CA private key becomes a top-tier secretIts compromise means universal impersonation for every device that trusts it
Every non-browser runtime needs configuringThe section below

Certificate pinning, HSTS and HPKP#

Pinning is the direct countermeasure to interception, and it works: an application that only accepts a specific public key or a specific issuer rejects the forged certificate outright. Mobile applications, package managers, some CLI tools and most agent software pin. The symptom is a hard failure with no bypass, often with an unhelpful message, and there is no way to fix it at the client. The only resolutions are to add the destination to the proxy's do-not-decrypt list (matched on SNI, or on destination IP when SNI is unavailable), or to stop pinning.

HSTS (RFC 6797) does not prevent interception when the corporate root is trusted, because the chain validates and no warning is shown. What HSTS does is remove the click-through: on an HSTS host, a browser presented with an untrusted certificate gives the user no option to proceed. That matters for the unmanaged device that never received the corporate root, which turns a nuisance into a hard failure. HSTS preloading extends this to the first visit.

HPKP (RFC 7469) was the web's attempt at pinning in a header, and it is dead: it was removed from Chrome in version 72 and was never widely deployed, largely because a misconfigured pin bricked a site for the pin's lifetime. Do not design around it. Its spirit survives in Expect-CT (also now deprecated) and, for applications, in in-app pinning.

The trust store table developers actually need#

The single most important fact: there is no such thing as "the" trust store. Installing a root into the OS store fixes browsers and OS-integrated tools, and does nothing for a JVM, a Python virtualenv, a Node process or a Go binary on macOS. One machine routinely has curl working and pip failing, and that is not a paradox.

Tool or runtimeWhere trust comes fromSetting or environment variableGotcha
Debian/Ubuntu OS store/etc/ssl/certs/ca-certificates.crtDrop the PEM in /usr/local/share/ca-certificates/corp.crt, run update-ca-certificatesThe file must end in .crt and be PEM, or it is silently skipped
RHEL/Fedora OS store/etc/pki/tls/certs/ca-bundle.crtDrop in /etc/pki/ca-trust/source/anchors/, run update-ca-trustForgetting update-ca-trust leaves the bundle unchanged
curl (OpenSSL builds)Compiled-in CA bundle path--cacert, or CURL_CA_BUNDLE=/path/corp-bundle.pemThe variable replaces the bundle; concatenate the corporate root with the system bundle rather than pointing at the root alone
Java$JAVA_HOME/lib/security/cacerts (JDK 9+); $JAVA_HOME/jre/lib/security/cacerts on JDK 8keytool -importcert -trustcacerts -alias corp -file corp.crt -keystore <cacerts> -storepass changeit, or -Djavax.net.ssl.trustStore=...Java ignores the OS store entirely on Linux. Every JDK installation has its own file, so an upgrade or a new container image loses the import
Python ssl/urllibOpenSSL default verify pathsSSL_CERT_FILE, SSL_CERT_DIRHonoured by the standard library, not by requests, which uses certifi
Python requestsThe certifi bundleREQUESTS_CA_BUNDLE, or verify="/path/corp.pem"requests also honours CURL_CA_BUNDLE as a fallback; a pip install --upgrade certifi reverts any file you edited in place
pipIts own vendored certifiPIP_CERT, pip config set global.cert /path/corp.pem, or --cert--trusted-host looks like the fix but disables verification for that host rather than trusting the CA
Node.jsCompiled-in Mozilla root listNODE_EXTRA_CA_CERTS=/path/corp.pemRead once at process start, so it must be set before launch. Ignored when code passes an explicit ca option. Recent Node.js releases add a --use-system-ca flag; check node --help on your version
npm and yarnNode's store, plus their own confignpm config set cafile /path/corp-bundle.pemcafile replaces the default roots, so the file must contain the public roots too, not only the corporate one. strict-ssl false disables verification and should not be used
GoSystem store on macOS and Windows; on Unix, a list of well-known bundle pathsSSL_CERT_FILE, SSL_CERT_DIRThese are honoured on Unix only. On macOS and Windows Go uses the platform verifier and ignores them, so the root must go into the OS keychain or store
Git (OpenSSL/GnuTLS builds)http.sslCAInfo, otherwise the OS bundlegit config --global http.sslCAInfo /path/corp-bundle.pem, or GIT_SSL_CAINFOhttp.sslVerify false is the dangerous shortcut. On Windows, git config --global http.sslBackend schannel makes Git use the Windows store, which is usually what you want
Docker daemon (registry pulls)OS store, plus per-registry directory/etc/docker/certs.d/<registry:port>/ca.crt, then restart the daemonThe directory name must include the port if the registry URL has one
AWS CLI and boto3certifiAWS_CA_BUNDLE, or ca_bundle in ~/.aws/configDistinct from REQUESTS_CA_BUNDLE despite botocore using requests-style plumbing
Ruby, BundlerOpenSSL defaultsSSL_CERT_FILE, BUNDLE_SSL_CA_CERTRubyGems may need :ssl_ca_cert in ~/.gemrc
Rust and CargoCargo uses libcurl; rustls-based tools use their own root listCARGO_HTTP_CAINFOA binary built against rustls with a compiled-in root list may ignore both the OS store and SSL_CERT_FILE unless it opted into native certificate loading
PHP and Composeropenssl.cafile in php.iniopenssl.cafile=/path/corp-bundle.pemComposer also reads SSL_CERT_FILE in some configurations; set both
Containers built FROM scratchNothingCopy a CA bundle into the imageA distroless or scratch image has no trust store at all, so every TLS call fails until one is added

The operational lesson: treat the corporate root as a build artefact. Bake it into base images, into the JDK provisioning step, and into developer machine setup, rather than fixing it per tool per incident. The wider tooling picture is covered in corporate proxies and developer tooling, and connectivity problems that look like TLS problems are often really proxy variable problems, which the no_proxy tester helps isolate.

Error strings, mapped to fixes#

Exact errorRuntimeMeaningFix
SSL certificate problem: unable to get local issuer certificate (curl exit code 60)curl, libcurl, Git on LinuxThe chain ends at an issuer curl's bundle does not containAdd the interception root to the bundle; set CURL_CA_BUNDLE or http.sslCAInfo
[SSL: CERTIFICATE_VERIFY_FAILED] certificate verify failed: unable to get local issuer certificate (_ssl.c:1006)PythonSame condition, seen through OpenSSL's Python bindingREQUESTS_CA_BUNDLE for requests, SSL_CERT_FILE for stdlib, PIP_CERT for pip
[SSL: CERTIFICATE_VERIFY_FAILED] certificate verify failed: self signed certificate in certificate chainPythonThe interception root itself appears in the presented chain and is untrustedSame fix; the wording differs only because the proxy sent the root
x509: certificate signed by unknown authorityGo (pre-1.20 wording)Go's verifier found no trusted anchorSSL_CERT_FILE on Unix; OS keychain or store on macOS and Windows
tls: failed to verify certificate: x509: certificate signed by unknown authorityGo 1.20 and laterSame fault, rewrapped errorAs above
x509: certificate is valid for X, not YGoName mismatch, not a trust problemThe proxy served a certificate for the wrong name, usually because no SNI was sent or an IP was used
PKIX path building failed: sun.security.provider.certpath.SunCertPathBuilderException: unable to find valid certification path to requested targetJavaThe JDK's cacerts lacks the rootkeytool -importcert into that JDK's cacerts, or set -Djavax.net.ssl.trustStore
unable to get local issuer certificate / UNABLE_TO_VERIFY_LEAF_SIGNATURE / SELF_SIGNED_CERT_IN_CHAINNode.jsNode's compiled-in root list lacks the CANODE_EXTRA_CA_CERTS, set before the process starts
server certificate verification failed. CAfile: /etc/ssl/certs/ca-certificates.crt CRLfile: noneGit built against GnuTLSThe OS bundle lacks the rootupdate-ca-certificates after installing the root, or set http.sslCAInfo
NET::ERR_CERT_AUTHORITY_INVALIDChromiumThe OS/browser store lacks the rootInstall the root into the OS or browser store; on an HSTS site there is no proceed option
SSL_ERROR_BAD_CERT_DOMAIN on an internal hostFirefoxInterception produced a certificate for a different nameCheck the proxy's handling of no-SNI and IP-literal connections
Handshake fails only for one application, everything else worksAnyThat application pinsAdd the destination to the do-not-decrypt list; no client-side fix exists

Two of these deserve emphasis because they are misread constantly. unable to get local issuer certificate means the issuer is unknown, which is exactly what interception produces, and it is not a sign that the remote site's certificate is bad. And a name mismatch (certificate is valid for X, not Y) is a different class of fault from a trust failure: adding the CA will not fix it.

The downgrade risk nobody sees#

The client evaluates only the proxy leg. Whatever the proxy negotiates upstream is invisible to it, and there is no mechanism by which the client can learn about it. Concretely, an interception product can:

  • negotiate TLS 1.2 upstream while presenting a TLS 1.3 connection to the client, so the client's own policy of "TLS 1.3 only" is satisfied while the traffic that leaves the building is not
  • accept upstream cipher suites the client would have refused, including ones without forward secrecy
  • skip hostname verification, revocation checking or CT enforcement on the upstream chain, which some products do by default for compatibility
  • pool and reuse upstream connections across different internal users, which can carry session state further than intended

Meanwhile the client's own protections are neutralised: CT enforcement does not apply to locally trusted roots, and pinning is either bypassed by policy or the connection simply fails. The security posture of every intercepted connection is the interception product's posture, not the client's. If interception is in place, the upstream leg's TLS configuration is something to audit deliberately, on the same schedule as the edge configuration you do control.

Client certificate authentication does not survive interception#

If a destination requires mTLS, an intercepting proxy cannot complete the upstream handshake, because it does not hold the client's private key. The server sends a CertificateRequest, the proxy has nothing to present, and the connection fails. From the user's side this looks like a timeout or a generic handshake error, with no indication that a client certificate was ever involved.

There are only two workable arrangements:

  1. Bypass. Add the destination to the do-not-decrypt list so the connection is tunnelled at layer 4. The proxy then routes on SNI alone, with the caveats in SNI-based routing, and the client's certificate reaches the real server.
  2. Re-originate. Install a client certificate on the proxy and let the proxy authenticate as itself. This changes who the server sees, which is usually unacceptable for an audited system, and it puts the identity's private key on a shared device.

Bypass is the normal answer. Note that a bypass list keyed on SNI degrades when clients enable Encrypted Client Hello, since the visible name becomes the client-facing server's public name. The related problem of forwarding an already-verified identity to a backend is covered in mutual TLS through a proxy.

Stated neutrally, because the constraints are jurisdictional rather than technical:

  • Decryption exposes credentials, session tokens, personal messages and health or financial data belonging to employees, not only corporate data. Whatever the proxy logs is now a repository of that material with the same protection requirements as the original.
  • Many jurisdictions require notice, and some require consent or works council agreement, before employee communications may be inspected. The requirement usually attaches to the monitoring, not to the decryption, so a policy of "decrypt but do not retain" may still be in scope.
  • Category-based do-not-decrypt lists (banking, healthcare, government, legal) are common practice and are usually driven by these obligations as much as by pinning failures.
  • The interception CA's private key is the highest-value secret in the estate. Anyone who obtains it can impersonate any site to any device that trusts the root, and the compromise is undetectable by the client. It belongs in an HSM, with the root offline and an issuing CA doing the signing, and its distribution scope should be as narrow as the policy allows.
  • Certificates minted by the proxy end up in browser caches, HSTS state and application logs, so evidence of interception persists on endpoints regardless of policy.

How to detect interception#

Three checks, in increasing order of certainty.

Compare the issuer chain. From the suspect machine:

bash
openssl s_client -connect example.com:443 -servername example.com </dev/null 2>/dev/null \
  | openssl x509 -noout -subject -issuer -dates -fingerprint -sha256

A public site should show a well-known public CA as issuer. An issuer naming your employer, your security vendor, or an unfamiliar internal name is interception. Short validity windows (days rather than months) and a missing SCT extension are corroborating signals.

Compare the fingerprint against a known-good vantage point. Run the same command from a machine outside the intercepted network, or from a mobile connection, and compare the SHA-256 fingerprints. They differ under interception, always, because the keys differ.

Use a pinned endpoint. Any tool that pins will fail loudly and immediately under interception, which turns a subtle question into a binary one. A quick equivalent is to fetch over HTTPS with a CA bundle containing only the public roots, for example curl --cacert /path/to/public-roots-only.pem https://example.com/: success means no interception on that path, failure with error 60 means the chain does not terminate in a public root. The mechanics of running curl through a proxy, including which variables affect it, are in curl through a proxy.

You can also inspect the certificate chain length and extensions. Interception products commonly copy the subject and SANs but drop AIA/OCSP URLs, certificate policies and SCTs, so a leaf with no OCSP responder URL for a site that normally has one is a strong hint.

Failure modes#

SymptomRoot causeFix
Browsers fine, pip install fails with CERTIFICATE_VERIFY_FAILEDThe root is in the OS store but pip uses vendored certifiPIP_CERT or pip config set global.cert
Everything fine on the host, all TLS fails inside a containerThe image has its own (or no) trust storeAdd the root during image build; scratch images have no bundle at all
Java application fails with PKIX path building failed after a JDK upgradecacerts is per JDK installation and the import was lostReimport, and automate it in the provisioning step
NODE_EXTRA_CA_CERTS set but Node still failsVariable set after process start, or code passes an explicit ca option that overrides itSet it in the process environment before launch; check for hard-coded ca in the client code
npm fails after setting cafilecafile replaces the default root list rather than adding to itPoint cafile at a bundle containing the public roots plus the corporate root
One mobile app or agent fails while the browser worksThat client pins its certificate or issuerAdd the host to the do-not-decrypt list; there is no client-side fix
Connections to an mTLS partner API time outThe proxy cannot present the client certificate upstreamBypass that destination at layer 4
x509: certificate is valid for <proxy default>, not example.comClient sent no SNI (IP literal, or an old client), so the proxy served its default certificateUse a hostname, or configure the proxy's no-SNI handling
Interception works, then a subset of users breaks after a browser updateThose clients enabled ECH, so SNI-based bypass and policy rules see only the public nameExpect degradation of SNI-keyed policy; plan destination-IP fallbacks
Everything works but security review flags weak upstream ciphersThe proxy's upstream TLS profile is separate from the client-facing oneAudit and configure the upstream profile explicitly

Frequently asked questions#

How does a proxy decrypt HTTPS without the site's private key?#

It does not decrypt the original session. It terminates the client's connection using a certificate it generates itself for that hostname, signed by a CA installed in the client's trust store, and opens a second, separate TLS connection to the real server. The client trusts the forged certificate because the signing CA is in its store, so verification succeeds and the proxy holds plaintext on both sides.

Why does curl fail with "unable to get local issuer certificate" but Chrome works?#

Because they use different trust stores. Chrome uses the operating system store (on most platforms), where the corporate root was installed, while curl uses its own compiled-in CA bundle, which was not updated. Point curl at a bundle containing the corporate root using --cacert or CURL_CA_BUNDLE, or add the root to the OS bundle and regenerate it.

What is the difference between CERTIFICATE_VERIFY_FAILED and "certificate signed by unknown authority"?#

Nothing, at the protocol level. They are Python's and Go's wordings for the same outcome: the presented chain does not terminate in a certificate that runtime trusts. The fix is identical, add the interception CA to that specific runtime's trust store, and the two errors appear on the same machine at the same time simply because two languages are involved.

Does certificate pinning stop TLS interception?#

Yes, for the pinned application. A pinned client compares the presented key or issuer against a hard-coded expectation, and the forged certificate does not match, so the connection is refused. The proxy operator cannot work around it from the network side; the destination has to be excluded from decryption, or the pin removed from the application.

Should I set NODE_TLS_REJECT_UNAUTHORIZED=0 to get past this?#

No. That disables certificate verification for the entire Node process, against every destination, which removes the protection TLS exists to provide. Use NODE_EXTRA_CA_CERTS to add the interception root instead, which keeps verification on and trusts exactly one additional anchor. The same reasoning applies to --trusted-host in pip, strict-ssl false in npm and http.sslVerify false in Git.

Can an intercepting proxy weaken the encryption I am using?#

Yes, and the client cannot detect it. The client only evaluates the connection to the proxy; the proxy independently chooses the TLS version, cipher suites and validation strictness for the upstream leg. A client enforcing TLS 1.3 and modern ciphers may be sitting in front of a proxy negotiating TLS 1.2 with an old cipher suite to the real server.

How do I tell whether my traffic is being intercepted?#

Inspect the certificate issuer with openssl s_client -connect host:443 -servername host piped into openssl x509 -noout -issuer -fingerprint -sha256. If the issuer is not a recognised public CA, or the SHA-256 fingerprint differs from what the same command returns on an unfiltered network, the connection is being intercepted. Certificate Transparency will not help, since locally installed roots are exempt from CT enforcement.

What happens to client certificate authentication under interception?#

It fails, because the proxy does not hold the client's private key and therefore cannot answer the server's CertificateRequest. The failure is usually reported as a generic handshake error or timeout with no mention of client certificates. Destinations requiring mTLS must be added to the proxy's do-not-decrypt list so the connection is tunnelled without decryption.

Primary sources#

Every normative claim on this page is checked against the specification or the vendor documentation listed here. Where behaviour is version dependent, the version is named in the text.

  1. RFC 8446: The Transport Layer Security (TLS) Protocol Version 1.3
  2. RFC 6797: HTTP Strict Transport Security (HSTS)
  3. RFC 7469: Public Key Pinning Extension for HTTP
  4. RFC 6962: Certificate Transparency
  5. RFC 5280: Internet X.509 PKI Certificate and CRL Profile
  6. curl: SSL certificate verification and CURL_CA_BUNDLE
  7. Node.js CLI options and environment variables
  8. Go crypto/x509 package documentation
  9. Python ssl module documentation
  10. Git http.sslCAInfo configuration

Found something wrong, or behaviour that differs on your version? Report it with the version number and a primary source. Anything substantive is fixed in the page and logged on the corrections page. See editorial standards for how pages are researched, sourced and reviewed.

More in tls and proxy security#