Skip to content
RU
← All articles

TLS Works in the Browser but Fails in curl, Java and Python

In short. A green padlock does not prove your TLS setup is correct. Browsers repair several server-side mistakes silently - most importantly, they fetch missing intermediate certificates on their own. Command-line tools and application runtimes do not. When a site opens fine in Chrome but fails in curl, Java or Python, the server is almost always serving an incomplete certificate chain.

Why the browser is your most forgiving client

Browsers are built to keep users on the page. Over years they accumulated compensations for common server misconfigurations, and those compensations run without telling anyone. Every other TLS client - curl, a Java service, a Python script, a payment gateway calling your webhook - validates strictly and fails loudly.

This is why "it works in my browser" is not evidence, and why the failure usually surfaces first through an integration rather than through visitors. The partner's server tried to call your API, validation failed, and nobody looked at a browser at all.

If a site opens in a browser but fails everywhere else, do not start by suspecting the client. Start by inspecting what your server actually sends during the handshake. In the large majority of these reports the server is at fault and the browser was hiding it.

Comparison of a browser silently completing a certificate chain against a command-line client rejecting the same incomplete chain
The same server, the same certificate, two verdicts. The browser repairs the gap; strict clients report it.

The four things browsers do that other clients do not

Browser behaviourEffectWhat strict clients do instead
Fetches missing intermediatesAn incomplete chain still validatesFail with "unable to get local issuer certificate"
Ships a frequently updated root storeNew CAs are trusted quicklyUse an OS or runtime store that may be years old
Always sends SNIThe right virtual host answersSome libraries omit it and get the default host
Offers a broad protocol and cipher setAlmost any server negotiatesNarrow defaults; may share nothing with the server

Reproduce the failure the way the strict client sees it

Before changing anything, get the server to show you its handshake. This command validates the way curl and most runtimes do, without a browser's assistance:

openssl s_client -connect example.com:443 -servername example.com \
  -verify_return_error < /dev/null 2>&1 \
  | grep -E 'Verify return code|Protocol|Cipher|subject=|issuer='

# How many certificates does the server actually send?
openssl s_client -connect example.com:443 -servername example.com -showcerts \
  < /dev/null 2>&1 | grep -c 'BEGIN CERTIFICATE'

The count is the fastest diagnostic in this whole article. A publicly trusted certificate almost always needs at least two certificates in the handshake: your leaf and one or more intermediates. If the count is 1, you have found the problem and can stop reading the rest of the causes.

Cause 1: an incomplete certificate chain

Certificate authorities do not sign your certificate with their root key. They sign it with an intermediate, and the intermediate is signed by the root. Clients ship only roots, so the server must supply the intermediates that connect its leaf to one.

When the server omits them, browsers quietly fetch the missing certificate using the URL inside the leaf's Authority Information Access extension. Strict clients do not, and report that they cannot build a path to a trusted root.

# See the AIA URL browsers use to repair the gap - and the CN of the issuer
openssl s_client -connect example.com:443 -servername example.com < /dev/null 2>/dev/null \
  | openssl x509 -noout -issuer -ext authorityInfoAccess

The fix is on the server, and it is usually a one-word mistake in the configuration - pointing at the leaf certificate rather than the full chain:

# nginx - ssl_certificate must be the FULL chain, not the leaf alone
ssl_certificate     /etc/letsencrypt/live/example.com/fullchain.pem;   # correct
# ssl_certificate   /etc/letsencrypt/live/example.com/cert.pem;        # leaf only - breaks strict clients
ssl_certificate_key /etc/letsencrypt/live/example.com/privkey.pem;

# Apache - modern versions read the chain from this one file
SSLCertificateFile    /etc/letsencrypt/live/example.com/fullchain.pem
SSLCertificateKeyFile /etc/letsencrypt/live/example.com/privkey.pem

Reload after changing it, then re-run the certificate count. It should now be two or more. The SSL checker reports the chain exactly as served, and the chain guide covers rebuilding it when the intermediate is not in your issuer's bundle.

Cause 2: the client's root store is not yours

Every TLS client trusts a list of root certificates, and those lists are maintained separately. A browser updates its store with the browser. An operating system updates with system packages. A Java runtime carries its own cacerts file, and Python's requests library uses a bundle shipped inside a package rather than the OS store.

The practical consequence is that a container image or a long-lived server can be years behind, and a certificate from a newer CA will fail there while working everywhere else. The signature of this cause is that the failure is specific to one environment and the chain itself is complete.

# Which bundle is this curl using?
curl -sI https://example.com --cacert /etc/ssl/certs/ca-certificates.crt > /dev/null \
  && echo 'OS bundle: OK'

# Java: is the issuing root present in the runtime truststore?
keytool -list -cacerts -storepass changeit 2>/dev/null | grep -ci 'trustedCertEntry'

# Python: which CA file is actually in use?
python3 -c "import ssl; print(ssl.get_default_verify_paths())"
python3 -c "import certifi; print(certifi.where())"

Resist the temptation to disable verification to make the error disappear. curl -k, verify=False and a trust-everything TrustManager turn a visible certificate problem into a silent interception risk, and they tend to survive in the codebase long after the real cause is forgotten.

Four separate trust stores - browser, operating system, Java runtime and Python package - each holding a different set of root certificates
Trust stores are maintained independently. A container built two years ago carries a two-year-old idea of who is trustworthy.

Cause 3: the client is not sending SNI

One address can host many certificates, so the client must announce which hostname it wants during the handshake. Browsers always do. Some older libraries, and openssl s_client when you forget -servername, do not - and the server answers with its default certificate, which is for a different name.

The resulting error is a hostname mismatch, which sends people to check DNS. The test is a direct comparison:

# With SNI - the correct virtual host answers
openssl s_client -connect example.com:443 -servername example.com < /dev/null 2>/dev/null \
  | openssl x509 -noout -subject

# Without SNI - which certificate does the server fall back to?
openssl s_client -connect example.com:443 < /dev/null 2>/dev/null \
  | openssl x509 -noout -subject

Different subjects mean the server depends on SNI. That is normal and correct, but it means any client that omits it will fail, and the fix belongs in the client.

Cause 4: no shared protocol or cipher

Correctly hardening a server by disabling old TLS versions breaks clients that only speak those versions. This is the one cause where the server change was right and the client is genuinely outdated.

# Which versions does the server accept?
for v in tls1_2 tls1_3; do
  printf '%-8s ' "$v"
  openssl s_client -connect example.com:443 -servername example.com -$v \
    < /dev/null 2>&1 | grep -m1 'Cipher is' || echo 'not accepted'
done

The signature here is different from a chain problem: the handshake fails before any certificate is discussed, so the error mentions protocol or cipher rather than trust. Old Java releases defaulting to TLS 1.0, and runtimes linked against very old OpenSSL builds, are the usual sources.

Reading the error message: which cause it names

ClientMessageCause
curlunable to get local issuer certificateIncomplete chain, or an outdated bundle
curlself-signed certificate in certificate chainAn interception proxy, or a private CA
JavaPKIX path building failedIncomplete chain, or root missing from cacerts
Pythoncertificate verify failed: unable to get local issuer certificateIncomplete chain, or a stale bundle
Gox509: certificate signed by unknown authorityIncomplete chain, or root not in the system store
Anyhostname mismatch / does not match certificateMissing SNI, or the wrong certificate installed
Anyno cipher / protocol version alertNo shared TLS version or cipher
Anycertificate has expiredGenuine expiry - renewal did not run or did not reload

Note how many rows point at the same cause. An incomplete chain accounts for most of the distinct-looking messages in this table, which is why the certificate count is the first thing to measure.

Chart mapping several different client error messages back to a single shared root cause of an incomplete chain
Different clients phrase the same defect differently. Four of these messages describe one server-side mistake.

Notes for specific runtimes

curl and libcurl

Uses the OS bundle by default. curl -v prints the chain it built and where verification stopped, which makes it the quickest reproduction tool. Never leave -k in a script that reached production.

Java

Has its own truststore rather than the OS one, so a root trusted by the system may still be unknown to the JVM. -Djavax.net.debug=ssl:handshake prints the full negotiation, including exactly which certificate broke path building.

Python

The standard library uses the OS store, while requests uses the bundle from certifi. The two can disagree, so a script can fail while curl on the same machine succeeds. Upgrading certifi resolves the stale-bundle case.

Go and .NET

Both use the system store on most platforms and validate strictly. Minimal container images frequently ship without any CA bundle at all, which produces an unknown-authority error for every host until the certificate package is installed.

How to check your own server

Run the SSL checker against your hostname and read three things: the number of certificates in the served chain, the negotiated protocol, and the validation result. A chain of one is the defect described above, regardless of how the site looks in a browser. If you also need to confirm the handshake itself completes, the handshake guide covers failures that happen before validation.

Because the failure mode here is invisible to browsers, it is also invisible to manual checking - a certificate renewal that reinstalls only the leaf breaks every integration while the site keeps looking perfect. SSL monitoring validates the chain on a schedule from outside your network and alerts when it changes, which catches a bad renewal on the day it happens rather than when a partner reports their webhook stopped working.

Timeline showing a certificate renewal that silently reduced a two-certificate chain to one, detected by a scheduled check
A renewal that reinstalls only the leaf leaves the site looking healthy in every browser while every strict client fails.

Frequently asked questions

The padlock is green. How can the certificate be wrong?

The padlock reports that the browser built a valid chain, not that your server supplied one. If an intermediate was missing, the browser fetched it and showed success. The padlock is a statement about the browser's result, not about your configuration.

Why does it fail only from one server?

Almost always an outdated or absent CA bundle in that environment - a long-lived VM, or a minimal container image with no certificate package. Check which bundle that runtime uses before concluding the remote server is at fault.

Is disabling verification an acceptable temporary fix?

It removes the only protection TLS gives you against an impostor, and temporary flags are unusually persistent. If you must ship before the server is fixed, pin the specific expected certificate rather than trusting everything.

How do I know whether the chain or the root store is the problem?

Count the certificates the server sends. If it sends one, the chain is incomplete and the server is at fault. If it sends the full chain and only one client fails, that client's trust store is the problem.

Does Let's Encrypt need special handling?

No, but its file layout invites the classic mistake: cert.pem is the leaf alone and fullchain.pem includes the intermediates. Pointing a web server at the first one produces exactly this failure, and it is the single most common instance of it.

Can a corporate proxy cause this?

Yes. Interception proxies re-sign traffic with their own CA, which employee browsers trust because it was installed centrally. A build agent or service on the same network usually does not trust it, so the same request fails there with a self-signed-in-chain error.

Checklist

  • Count the certificates in the served chain before investigating anything else.
  • Point the web server at the full chain file, not the leaf certificate.
  • Reload the server after changing certificate paths, and re-measure.
  • Verify with -verify_return_error; require Verify return code: 0.
  • Check the failing client's trust store separately from the server's chain.
  • Compare handshakes with and without SNI when the error mentions hostname mismatch.
  • Confirm a shared TLS version before assuming a certificate problem.
  • Never leave verification disabled as a fix.
  • Re-verify the chain after every renewal - a bad renewal is invisible in browsers.
  • Monitor the chain from outside so a silent regression is caught on the day it lands.

Check your website right now

Check your site's SSL →
More articles: SSL/TLS
SSL/TLS
SSL Certificate Chain: How to Verify and Fix an Incomplete One
15.04.2026 · 1 154 views
SSL/TLS
Expired SSL Certificate: How to Fix NET::ERR_CERT_DATE_INVALID
15.04.2026 · 1 073 views
SSL/TLS
Fix ERR_CERT_AUTHORITY_INVALID: Causes and Solutions
13.07.2026 · 1 013 views
SSL/TLS
SSL Handshake Failed: Root Causes and Step-by-Step Diagnosis
15.04.2026 · 935 views