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.
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_FAILEDandx509: certificate signed by unknown authorityare 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#
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, succeedsTwo 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#
| Capability | Enabled by decryption | Notes |
|---|---|---|
| Data loss prevention on uploads | Yes | The primary business justification; cannot be done on ciphertext |
| Malware scanning of downloads | Yes | Also the reason large file scanning adds latency |
| URL and content policy beyond hostname | Yes | Without decryption, policy is limited to SNI, and SNI is not an authorisation boundary |
| Detecting exfiltration to allowed hosts | Yes | The case SNI filtering explicitly cannot cover |
| Full request logging for audit | Yes | Which is also the privacy cost |
| Cost | Mechanism |
|---|---|
| Certificate pinning breaks | The pinned key is not in the forged chain |
| Client certificate auth breaks | The proxy has no access to the client's private key |
| Crypto strength becomes the proxy's, not the client's | The client can only see the proxy leg |
| Certificate Transparency enforcement is bypassed | Locally installed roots are exempt from CT requirements by design |
| The interception CA private key becomes a top-tier secret | Its compromise means universal impersonation for every device that trusts it |
| Every non-browser runtime needs configuring | The 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 runtime | Where trust comes from | Setting or environment variable | Gotcha |
|---|---|---|---|
| Debian/Ubuntu OS store | /etc/ssl/certs/ca-certificates.crt | Drop the PEM in /usr/local/share/ca-certificates/corp.crt, run update-ca-certificates | The file must end in .crt and be PEM, or it is silently skipped |
| RHEL/Fedora OS store | /etc/pki/tls/certs/ca-bundle.crt | Drop in /etc/pki/ca-trust/source/anchors/, run update-ca-trust | Forgetting update-ca-trust leaves the bundle unchanged |
| curl (OpenSSL builds) | Compiled-in CA bundle path | --cacert, or CURL_CA_BUNDLE=/path/corp-bundle.pem | The 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 8 | keytool -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/urllib | OpenSSL default verify paths | SSL_CERT_FILE, SSL_CERT_DIR | Honoured by the standard library, not by requests, which uses certifi |
Python requests | The certifi bundle | REQUESTS_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 |
| pip | Its own vendored certifi | PIP_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.js | Compiled-in Mozilla root list | NODE_EXTRA_CA_CERTS=/path/corp.pem | Read 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 yarn | Node's store, plus their own config | npm config set cafile /path/corp-bundle.pem | cafile 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 |
| Go | System store on macOS and Windows; on Unix, a list of well-known bundle paths | SSL_CERT_FILE, SSL_CERT_DIR | These 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 bundle | git config --global http.sslCAInfo /path/corp-bundle.pem, or GIT_SSL_CAINFO | http.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 daemon | The directory name must include the port if the registry URL has one |
| AWS CLI and boto3 | certifi | AWS_CA_BUNDLE, or ca_bundle in ~/.aws/config | Distinct from REQUESTS_CA_BUNDLE despite botocore using requests-style plumbing |
| Ruby, Bundler | OpenSSL defaults | SSL_CERT_FILE, BUNDLE_SSL_CA_CERT | RubyGems may need :ssl_ca_cert in ~/.gemrc |
| Rust and Cargo | Cargo uses libcurl; rustls-based tools use their own root list | CARGO_HTTP_CAINFO | A 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 Composer | openssl.cafile in php.ini | openssl.cafile=/path/corp-bundle.pem | Composer also reads SSL_CERT_FILE in some configurations; set both |
Containers built FROM scratch | Nothing | Copy a CA bundle into the image | A 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 error | Runtime | Meaning | Fix |
|---|---|---|---|
SSL certificate problem: unable to get local issuer certificate (curl exit code 60) | curl, libcurl, Git on Linux | The chain ends at an issuer curl's bundle does not contain | Add 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) | Python | Same condition, seen through OpenSSL's Python binding | REQUESTS_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 chain | Python | The interception root itself appears in the presented chain and is untrusted | Same fix; the wording differs only because the proxy sent the root |
x509: certificate signed by unknown authority | Go (pre-1.20 wording) | Go's verifier found no trusted anchor | SSL_CERT_FILE on Unix; OS keychain or store on macOS and Windows |
tls: failed to verify certificate: x509: certificate signed by unknown authority | Go 1.20 and later | Same fault, rewrapped error | As above |
x509: certificate is valid for X, not Y | Go | Name mismatch, not a trust problem | The 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 target | Java | The JDK's cacerts lacks the root | keytool -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_CHAIN | Node.js | Node's compiled-in root list lacks the CA | NODE_EXTRA_CA_CERTS, set before the process starts |
server certificate verification failed. CAfile: /etc/ssl/certs/ca-certificates.crt CRLfile: none | Git built against GnuTLS | The OS bundle lacks the root | update-ca-certificates after installing the root, or set http.sslCAInfo |
NET::ERR_CERT_AUTHORITY_INVALID | Chromium | The OS/browser store lacks the root | Install 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 host | Firefox | Interception produced a certificate for a different name | Check the proxy's handling of no-SNI and IP-literal connections |
| Handshake fails only for one application, everything else works | Any | That application pins | Add 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:
- 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.
- 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.
Privacy and legal considerations#
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:
openssl s_client -connect example.com:443 -servername example.com </dev/null 2>/dev/null \
| openssl x509 -noout -subject -issuer -dates -fingerprint -sha256A 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#
| Symptom | Root cause | Fix |
|---|---|---|
Browsers fine, pip install fails with CERTIFICATE_VERIFY_FAILED | The root is in the OS store but pip uses vendored certifi | PIP_CERT or pip config set global.cert |
| Everything fine on the host, all TLS fails inside a container | The image has its own (or no) trust store | Add the root during image build; scratch images have no bundle at all |
Java application fails with PKIX path building failed after a JDK upgrade | cacerts is per JDK installation and the import was lost | Reimport, and automate it in the provisioning step |
NODE_EXTRA_CA_CERTS set but Node still fails | Variable set after process start, or code passes an explicit ca option that overrides it | Set it in the process environment before launch; check for hard-coded ca in the client code |
npm fails after setting cafile | cafile replaces the default root list rather than adding to it | Point cafile at a bundle containing the public roots plus the corporate root |
| One mobile app or agent fails while the browser works | That client pins its certificate or issuer | Add the host to the do-not-decrypt list; there is no client-side fix |
| Connections to an mTLS partner API time out | The proxy cannot present the client certificate upstream | Bypass that destination at layer 4 |
x509: certificate is valid for <proxy default>, not example.com | Client sent no SNI (IP literal, or an old client), so the proxy served its default certificate | Use a hostname, or configure the proxy's no-SNI handling |
| Interception works, then a subset of users breaks after a browser update | Those clients enabled ECH, so SNI-based bypass and policy rules see only the public name | Expect degradation of SNI-keyed policy; plan destination-IP fallbacks |
| Everything works but security review flags weak upstream ciphers | The proxy's upstream TLS profile is separate from the client-facing one | Audit 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.
- RFC 8446: The Transport Layer Security (TLS) Protocol Version 1.3
- RFC 6797: HTTP Strict Transport Security (HSTS)
- RFC 7469: Public Key Pinning Extension for HTTP
- RFC 6962: Certificate Transparency
- RFC 5280: Internet X.509 PKI Certificate and CRL Profile
- curl: SSL certificate verification and CURL_CA_BUNDLE
- Node.js CLI options and environment variables
- Go crypto/x509 package documentation
- Python ssl module documentation
- 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.