Home DevOps x509 Signed by Unknown Authority — Fix TLS Trust
Intermediate 6 min · September 23, 2026

x509 Signed by Unknown Authority — Fix TLS Trust

Append the missing intermediate to your chain and install private CA certs into each trust store.

N
Naren Founder & Principal Engineer

20+ years shipping production infrastructure and CI/CD at scale. Lessons pulled from things that broke in production.

Follow
Production
production tested
September 23, 2026
last updated
1,905
articles · all by Naren
Before you start⏱ 17 min
  • Basic TLS and certificate concepts
  • Comfortable running openssl commands
  • Access to server configs for debugging
 ● Production Incident 🔎 Debug Guide
Quick Answer
  • 'Signed by unknown authority' means the client can't chain your cert to a root it trusts — usually a missing intermediate, not an expired cert
  • Verify the chain with openssl s_client -showcerts and openssl verify -CAfile before touching any config
  • Private CAs must be installed into every store that matters: OS bundle, Java cacerts, NODE_EXTRA_CA_CERTS, and Python certifi
  • Never skip verification in code to 'fix' it — that hides the error and ships a man-in-the-middle hole to production
✦ Definition~90s read
What is x509 Signed by Unknown Authority Fix?

x509: certificate signed by unknown authority is a TLS client error meaning the client received the server's certificate but couldn't build a path from it to any root certificate in its trust store. The mechanism is chain building: the server presents its leaf certificate plus (ideally) intermediate certificates; the client walks each signature upward until it reaches a self-signed root it already trusts.

Imagine showing up with a visitor badge signed by your manager, but the guard has never heard of your manager.

If any link is missing — the server didn't send the intermediate, or the root is a private CA the client never heard of — verification fails and the handshake aborts before a single byte of application data flows.

The two dominant causes cover nearly every incident. First, the incomplete chain: the server sends only the leaf, omitting the intermediate, so clients can't bridge the gap between your cert and the public root. Browsers often still work because they cache intermediates from other sites, which is why 'it works in Chrome but fails in curl' is the classic symptom.

Second, the private CA: internal PKI, corporate proxies, and dev clusters issue certs from a company root that no default trust store contains, so every fresh client rejects them until the CA is installed.

What this error is NOT: it's not an expired certificate (that's 'certificate has expired'), not a hostname mismatch ('certificate is not valid for host'), and not a clock problem on its own (though wildly wrong clocks can mimic trust failures). Each of those has a distinct message and a distinct fix.

Unknown authority specifically indicts the chain or the store — so the investigation targets what the server sends (openssl s_client -showcerts) and what the client trusts (its CA bundle), never the dates or the SANs.

Plain-English First

Imagine showing up with a visitor badge signed by your manager, but the guard has never heard of your manager. The badge isn't fake — the guard just can't verify the signature. That's this error: your certificate is real, but it's missing the middle link (the intermediate) to an authority the client trusts — or your company runs its own authority unknown to the client. The fix is handing over the complete chain, or installing your company's authority into the client's trusted list.

Your deploy goes green, your app boots, and then every outbound HTTPS call dies with x509: certificate signed by unknown authority. Your monitoring lights up, your pods crash-loop, and the certificate itself is perfectly valid — it expires in 300 days, the domain matches, and browsers accept it without complaint. The problem isn't the certificate. It's the chain of trust behind it.

This error strikes in the gaps between systems: a Go microservice calling an internal API behind a private CA, a Jenkins agent pulling from a registry with a fresh intermediate, a Python ETL job that worked on your laptop but fails in a slim container missing the OS bundle. Each runtime carries its own trust store, so a chain that's complete for Chrome can be broken for Java, Node, or curl in the same environment.

The dangerous 'fix' is everywhere on forums: set InsecureSkipVerify, NODE_TLS_REJECT_UNAUTHORIZED=0, or verify=False. That silences the error by disabling the check that protects your traffic — the equivalent of firing the security guard instead of showing ID.

By the end of this article you'll diagnose chain problems with openssl in under a minute, serve complete chains from your servers, install private CAs into every store that matters, and know exactly why verification must never be skipped.

How Chain Building Fails: Leaf, Intermediate, and Root

TLS trust is a signature chain with exactly three roles. The leaf (your server's certificate) is signed by an intermediate, which is signed by a root that clients ship in their trust stores. The client verifies each signature link by link until it lands on a root it already trusts. Break any link — omit the intermediate, or terminate at a root nobody trusts — and the whole handshake dies with 'signed by unknown authority'.

Servers are responsible for sending everything except the root: leaf plus all intermediates. Roots are never sent because the client must already have them; a server that sends its root is misconfigured (harmless, but a smell). The classic failure is serving cert.pem (leaf only) instead of fullchain.pem (leaf plus intermediate). Public CAs issue both files precisely because this mistake is so common, yet renewal scripts still grab the wrong one.

Clients differ in forgiveness. Browsers cache intermediates seen on other sites and can fetch missing ones via AIA URLs, so a broken chain often looks fine in Chrome. Go's crypto/x509, Java's PKIX validator, Python's ssl module, and curl against minimal bundles build strictly from what's served plus local roots. That's why the signature symptom of this bug is 'works in the browser, fails everywhere else' — and why server-side verification with strict settings is the only honest test. Keep a known-good reference: run the same s_client command against a host serving fullchain correctly and compare side by side — the missing intermediate block is obvious once you've seen a healthy chain.

BASH
1
2
3
4
5
6
7
8
9
10
11
12
13
# See exactly what the server sends (strict clients see only this)
openssl s_client -connect api.example.com:443 -servername api.example.com -showcerts </dev/null 2>/dev/null | grep -E 's:|i:|BEGIN CERT'

# s: = subject (who the cert is for), i: = issuer (who signed it)
# Healthy public chain shows 2+ certs:
#   0 s:CN = api.example.com / i:C = US, O = Intermediate CA
#   1 s:O = Intermediate CA / i:O = Public Root CA
# Broken chain shows only cert 0 — the intermediate never left the server

# Verify the served chain against the system bundle (strict, like Go/Java)
openssl s_client -connect api.example.com:443 -servername api.example.com -showcerts </dev/null 2>/dev/null | awk '/BEGIN CERT/,/END CERT/' > /tmp/chain.pem
openssl verify -CAfile /etc/ssl/certs/ca-certificates.crt /tmp/chain.pem
# Want: /tmp/chain.pem: OK. Anything else names your broken link.
⚠ Browsers Lie About Chain Health
Chrome and Firefox cache intermediates and fetch missing ones automatically. A chain that browsers accept can still fail every Go, Java, Python, and curl client you run — always verify with openssl, never with a browser.
📊 Production Insight
Log the served chain depth in your TLS monitoring, not just expiry dates. Every renewal is a chance to deploy a leaf-only file, and depth monitoring catches it before any client does.
🎯 Key Takeaway
Servers must send leaf plus intermediates; clients verify up to a locally trusted root. Strict clients expose the gaps browsers hide.

Serve the Full Chain: fullchain.pem on Every Terminator

The server-side fix is almost always one filename: point your TLS terminator at the full chain file instead of the leaf. Certbot and most ACME clients write fullchain.pem (leaf plus intermediate) alongside cert.pem (leaf only) and chain.pem (intermediate only) — the renewal bug is grabbing cert.pem out of habit. Nginx, Apache, HAProxy, and ingress controllers all want the bundle; only the exact directive name differs.

Verify from the client's perspective after every change, not by re-reading your config. Config files say what you intended; s_client shows what you served. Count the BEGIN CERTIFICATE blocks (2 for a standard public chain, more for cross-signed roots), verify strict OK against the system bundle, and confirm the issuer of cert 0 names the intermediate rather than the root or itself.

Automate this into renewals. The deploy hook that installs a renewed certificate should run the s_client depth check and roll back on failure — a 10-second gate that would have prevented the 52-minute outage in this article's incident. Manual 'it renewed fine' checks don't survive the 3 AM auto-renewal six months later. Keep the previous known-good fullchain file versioned alongside the new one during rotation week, so a bad renewal rolls back with a single symlink flip instead of a reissue scramble.

/etc/nginx/sites-available/api.confNGINX
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
# WRONG (leaf only — strict clients fail, browsers forgive):
# ssl_certificate /etc/letsencrypt/live/api.example.com/cert.pem;

# RIGHT (leaf + intermediate — every client can build the chain):
server {
    listen 443 ssl;
    server_name api.example.com;
    ssl_certificate /etc/letsencrypt/live/api.example.com/fullchain.pem;
    ssl_certificate_key /etc/letsencrypt/live/api.example.com/privkey.pem;
    ssl_trusted_certificate /etc/letsencrypt/live/api.example.com/chain.pem;
}

# Post-renewal gate (run in your --deploy-hook before reload):
# test "$(openssl s_client -connect localhost:443 -servername api.example.com -showcerts </dev/null 2>/dev/null | grep -c 'BEGIN CERTIFICATE')" -ge 2 || exit 1
# nginx -t && systemctl reload nginx
📊 Production Insight
Pin the renewal hook to check served depth, not file existence. Files can exist and still be wrong — only the live handshake proves what clients receive.
🎯 Key Takeaway
Serve fullchain.pem (leaf plus intermediate) on every TLS terminator, and gate renewals on a live s_client depth check.

Private CAs: Install the Root Into the OS Trust Store

Internal PKI, corporate TLS-intercepting proxies, and dev clusters issue certificates from a company root that no public bundle contains. Every client rejects these by design until you install the root — that's the trust model working correctly, not a bug. The OS store is the foundation: most Linux tools (curl, wget, git, apt) read the system bundle, so installing there fixes the broadest set of clients in one move.

The procedure is distribution-specific but conceptually identical: drop the root .crt into the anchors directory, run the update tool, and verify with openssl verify. On Debian/Ubuntu that's /usr/local/share/ca-certificates plus update-ca-certificates; on RHEL it's /etc/pki/ca-trust/source/anchors plus update-ca-trust. Containers need the same steps baked into the image — a root installed on the host doesn't propagate into containers, which is why the app 'works on the VM but fails in the pod'.

Corporate proxies deserve special mention. When a proxy re-signs external traffic with a company root, every client behind it — including your CI runners and language package managers — needs that root. The symptom is bewildering ('even google.com fails verification') until you realize the issuer on every cert names your company proxy, not a public CA.

BASH
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
# Debian/Ubuntu: install a private root into the OS store
sudo cp corp-root-ca.crt /usr/local/share/ca-certificates/corp-root-ca.crt
sudo update-ca-certificates
# Expected: "1 added" — bundle rebuilt at /etc/ssl/certs/ca-certificates.crt

# RHEL/CentOS/Fedora equivalent
sudo cp corp-root-ca.crt /etc/pki/ca-trust/source/anchors/
sudo update-ca-trust extract

# Verify the root is trusted for your leaf (strict check, no app involved)
openssl verify -CAfile /etc/ssl/certs/ca-certificates.crt /tmp/internal-leaf.pem
# Want: /tmp/internal-leaf.pem: OK

# Bake into containers — host stores never propagate into images
# Dockerfile:
# COPY corp-root-ca.crt /usr/local/share/ca-certificates/
# RUN apt-get update && apt-get install -y ca-certificates && update-ca-certificates
📊 Production Insight
Base-image updates can silently drop custom roots if the Dockerfile step order puts update-ca-certificates before the COPY. Keep COPY-then-update adjacent and verify the root exists in CI with a one-line openssl verify.
🎯 Key Takeaway
Install private roots into the OS bundle on every host and image. Containers never inherit host trust — bake the root into the Dockerfile.

Language Stores: Java cacerts, Node, and Python certifi

Fixing the OS store and declaring victory is the second most common way this incident recurs — because Java, Node.js, and Python each carry trust stores that ignore (partially or fully) the system bundle. Java is the strictest outlier: the JVM reads only its own cacerts file and never consults the OS store, so a root installed via update-ca-certificates remains invisible to every Java service on the box until you keytool -import it.

Node.js uses a bundled Mozilla root list compiled into the binary and ignores the OS store by default. The supported override is NODE_EXTRA_CA_CERTS pointing at a PEM bundle — note it replaces nothing, it appends your extra CAs to the built-in list. Python's requests library goes through certifi (its own pinned bundle via pip), while the stdlib ssl module typically uses the OS store — so the same host can have Python scripts that pass with urllib and fail with requests, depending purely on which bundle each path loads.

Go is the pleasant exception: crypto/x509 reads the OS store on Linux (and the system keychain on macOS/Windows), so OS-level installation suffices. Know your runtime's source of truth before you start installing — check keytool -list, NODE_EXTRA_CA_CERTS, and certifi.where() first, and you'll fix the right store on the first attempt instead of the third. When two runtimes on one host disagree, capture both probes' outputs before changing anything — the pair (system curl OK, JVM FAIL) is the fastest possible proof of a store mismatch and ends all debate about whose config is wrong.

BASH
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
# Java: import the private root into the JVM's own store (OS store is ignored)
sudo keytool -import -trustcacerts -alias corp-root-ca \
  -file corp-root-ca.crt -cacerts -storepass changeit -noprompt
keytool -list -cacerts -storepass changeit 2>/dev/null | grep -i corp-root

# Node: append extra CAs without replacing the built-in Mozilla list
export NODE_EXTRA_CA_CERTS=/etc/ssl/certs/ca-bundle-with-corp.crt
node -e "console.log(process.env.NODE_EXTRA_CA_CERTS)"
node -e "require('https').get('https://internal.example.com', r => console.log(r.statusCode))"

# Python: find which bundle each path uses, then pin the right one
python3 -c "import certifi; print(certifi.where())"
REQUESTS_CA=/etc/ssl/certs/ca-certificates.crt python3 probe.py
pip install --upgrade certifi  # stale certifi bundles cause phantom failures

# Go: uses the OS store — verify the binary sees your root
go run probe.go  # crypto/x509 loads /etc/ssl/certs on Linux automatically
🔥Never Set InsecureSkipVerify or verify=False
Disabling verification silences x509 errors by removing the protection itself — attackers can then intercept your traffic undetected. Fix the chain or the store; the skip flag is never the fix, not even temporarily.
📊 Production Insight
Mixed-runtime fleets need a trust matrix in the runbook: one row per runtime, one column per CA, each cell verified by CI. The JVM row is the one teams forget until the 2 AM page.
🎯 Key Takeaway
Java uses only cacerts, Node appends via NODE_EXTRA_CA_CERTS, Python splits between certifi and the OS store. Fix the store your failing runtime reads.

Curl, Python, and Go: Reproduce Per-Runtime Before You Fix

Reproduce the failure with the smallest possible client before changing anything — a one-line probe per runtime that isolates trust from application logic. If curl fails with 'unable to get local issuer certificate' while openssl verify passes, the difference is which bundle each loaded, and that gap is your diagnosis. Probes also give you a regression test: the same one-liner becomes the deploy gate and the monitoring check.

Curl's --cacert flag lets you test candidate bundles without installing anything, which is perfect for confirming 'this bundle would fix it' before you roll it fleet-wide. Python probes should exercise both requests (certifi path) and urllib (stdlib path) since they can disagree on the same host. A Go probe using crypto/x509 with SystemCertPool mirrors exactly what your microservices do at startup, including the strict no-AIA-fetching behavior that makes Go the canary for chain problems.

Keep these probes in your runbook as copy-paste blocks. During an incident, the engineer on call shouldn't be composing TLS test harnesses from memory — they should paste, run, and read OK versus FAIL within sixty seconds. After the fix, re-run every runtime probe from the same runbook block and paste the outputs into the incident record — per-runtime OKs are the only closure evidence that survives the next on-call rotation.

BASH
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
# curl: test a candidate bundle WITHOUT installing it fleet-wide
curl --cacert /tmp/candidate-bundle.crt https://internal.example.com/health -s -o /dev/null -w '%{http_code}\n'
# 200 = this bundle fixes it. 60 (exit code) = still untrusted.
curl -v https://internal.example.com/health 2>&1 | grep -iE 'issuer|verify|subject'

# Python: exercise BOTH trust paths (they can disagree on one host)
python3 - <<'EOF'
import ssl, socket, requests
# stdlib path (OS store)
ctx = ssl.create_default_context()
with ctx.wrap_socket(socket.socket(), server_hostname='internal.example.com') as s:
    s.connect(('internal.example.com', 443))
    print('stdlib OK:', s.getpeercert()['subject'])
# requests path (certifi bundle)
r = requests.get('https://internal.example.com/health', timeout=10)
print('requests OK:', r.status_code)
EOF
📊 Production Insight
Save every probe's output from the incident into the postmortem. 'curl exit 60, openssl verify OK, Go FAIL' is a fingerprint that makes the next identical page solvable in minutes.
🎯 Key Takeaway
Probe each runtime with a one-liner before fixing. Candidate bundles can be tested with --cacert before fleet-wide rollout.

Prevention: Renewal Gates, Depth Monitoring, and CA Rotation Drills

x509 outages are almost never novel — they're renewals that dropped the intermediate, rotations that forgot a store, or base images that lost a root. All three are preventable with the same trio: verify at deploy time, monitor the live chain, and drill rotations. The deploy gate runs s_client depth plus strict openssl verify against every TLS endpoint you serve, in CI and in the renewal hook, and blocks promotion on any non-OK.

Live monitoring needs two probes per endpoint: a browser-like check (uptime, expiry date) and a strict check (Go or openssl verify with no AIA fetching). The incident in this article stayed invisible for 20 minutes precisely because only the forgiving check existed. Alert on depth changes too — a chain that drops from 2 certificates to 1 is a renewal bug even if nothing has failed yet.

Finally, drill private-CA rotations like the outage they are. Rotating a corp root touches OS bundles, JVM cacerts, Node env vars, certifi pins, and container images simultaneously; each missed store is a future page. Maintain the trust matrix, rotate in staging first with per-runtime probes, and keep the old root trusted during a crossover window so slow-to-update clients don't hard-fail at cutover.

BASH
1
2
3
4
5
6
7
8
9
10
11
12
# Deploy gate: every endpoint must serve a verifiable chain (CI + renewal hook)
for host in api.example.com internal.example.com registry.example.com; do
  depth=$(openssl s_client -connect "$host:443" -servername "$host" -showcerts </dev/null 2>/dev/null | grep -c 'BEGIN CERTIFICATE')
  result=$(openssl s_client -connect "$host:443" -servername "$host" -showcerts </dev/null 2>/dev/null | awk '/BEGIN CERT/,/END CERT/' | openssl verify -CAfile /etc/ssl/certs/ca-certificates.crt /dev/stdin 2>&1)
  echo "$host depth=$depth verify=$result"
done

# Expiry early-warning (page at 21 days, not at midnight of expiry day)
openssl s_client -connect api.example.com:443 </dev/null 2>/dev/null | openssl x509 -noout -checkend 1814400 || echo 'RENEW WITHIN 21 DAYS'

# Cron/Blackbox: strict probe every minute, alert on anything but OK
# blackbox.yml tls_config uses the same system bundle your Go services load
📊 Production Insight
Alert on chain-depth changes, not just failures. Depth dropping from 2 to 1 after a renewal is a confirmed bug with zero user impact yet — the cheapest page you'll ever get.
🎯 Key Takeaway
Gate deploys on strict chain verification, monitor depth plus validity per endpoint, and rotate private CAs with per-runtime probes and a crossover window.
● Production incidentPOST-MORTEMseverity: high

The Missing Intermediate That Broke 40 Go Services for 52 Minutes

Symptom
At 2:11 PM, inter-service error rates jumped from 0.1% to 100% across 40 Go services within 90 seconds of a certificate renewal. Every service-to-service call failed with x509: certificate signed by unknown authority. Browsers hitting the same endpoints showed no warnings, and the load balancer health checks (which used a cached intermediate) stayed green — so the outage was invisible to uptime monitors for the first 20 minutes until internal API consumers started paging.
Assumption
The team assumed a bad certificate push or a compromised CA, and spent 25 minutes reissuing the certificate twice and restarting pods. The new certs behaved identically because the issuance was never the problem. One engineer also suspected a Go version regression from a base-image bump the same morning, which sent a second squad down a container-diff rabbit hole while the real cause sat in a 3-line Nginx config.
Root cause
The renewal automation wrote only cert.pem (the leaf) into the Nginx ssl_certificate path instead of the fullchain.pem (leaf plus intermediate). Nginx served the leaf alone. Browsers tolerated it via cached intermediates from other sites, but Go's crypto/x509 builds chains strictly from what the server sends plus system roots — no cache, no AIA fetching — so every Go client failed. A single openssl s_client -showcerts run would have shown 1 certificate instead of 2, but nobody ran it for the first 35 minutes.
Fix
Three changes shipped the same day. First, ssl_certificate was pointed at fullchain.pem and verified with openssl s_client -showcerts | grep -c 'BEGIN CERT' returning 2 before the change closed. Second, the renewal automation gained a post-renewal gate: it serves the chain to a Go-based prober that performs a strict verification, and rolls back if depth doesn't verify. Third, uptime monitoring added a Go strict-mode check alongside the browser check, so a chain that's valid for browsers but broken for strict clients pages within 60 seconds.
Key lesson
  • Browser-green doesn't mean chain-complete. Browsers cache intermediates and fetch missing ones; Go, Java, and curl in minimal containers don't. Monitor with the strictest client you run, not the most forgiving one.
  • Renewal automation must verify what it serves, not just what it received. The issuer returned a correct chain — the deploy step dropped half of it. A post-renewal s_client depth check catches this in seconds.
  • Mutual suspicion between 'bad cert' and 'bad client' wastes the golden hour. One openssl command distinguishes them instantly: if the server sends fewer certs than the chain needs, it's the server.
Production debug guideFive openssl-centered checks that separate server-chain bugs from client-store bugs in minutes.5 entries
Symptom · 01
Clients reject the cert but browsers accept it without warnings
Fix
Count what the server actually sends — browsers forgive a missing intermediate, strict clients don't: openssl s_client -connect api.example.com:443 -servername api.example.com -showcerts </dev/null 2>/dev/null | grep -c 'BEGIN CERTIFICATE'. A count of 1 means the intermediate is missing (expect 2+ for a public chain). Fix the server to serve fullchain.pem, then re-run until the count matches the chain depth.
Symptom · 02
You need a definitive pass/fail on the chain independent of any app
Fix
Verify strictly with openssl using the system bundle, exactly like a minimal client would: openssl s_client -connect api.example.com:443 -servername api.example.com -showcerts </dev/null 2>/dev/null | awk '/BEGIN CERT/,/END CERT/' > /tmp/chain.pem && openssl verify -CAfile /etc/ssl/certs/ca-certificates.crt /tmp/chain.pem. An 'OK' means the chain is servable; any 'unable to get local issuer certificate' confirms a missing intermediate or unknown root.
Symptom · 03
Internal services use a private CA and every fresh host rejects it
Fix
Check whether the private root is present in the OS store before blaming the app: ls /usr/local/share/ca-certificates/ | grep -i corp; awk '/BEGIN CERT/{n++} END{print n" certs in bundle"}' /etc/ssl/certs/ca-certificates.crt; openssl verify -CAfile /etc/ssl/certs/ca-certificates.crt /tmp/leaf.pem. If the corp root is absent, copy the .crt into /usr/local/share/ca-certificates/ and run update-ca-certificates, then re-verify — don't touch application code.
Symptom · 04
System tools trust the endpoint but Java, Node, or Python still fail
Fix
Each runtime has its own store — query each one directly: keytool -list -cacerts -storepass changeit 2>/dev/null | grep -i corp (Java); node -e "console.log(process.env.NODE_EXTRA_CA_CERTS)" plus openssl-style check via NODE_EXTRA_CA_CERTS=/etc/ssl/certs/ca-bundle.crt node test.js (Node); python -c "import certifi; print(certifi.where())" and REQUESTS_CA=/etc/ssl/certs/ca-certificates.crt python probe.py (Python). Install the root into whichever store your failing runtime actually reads.
Symptom · 05
You need to prove the fix and catch the next chain break before clients do
Fix
Gate deploys on a strict verification probe and monitor continuously: echo | openssl s_client -connect api.example.com:443 2>/dev/null | openssl x509 -noout -issuer -subject (issuer must name the intermediate, not the root); then add a cron or Blackbox exporter probe running openssl verify against the live chain every minute. Alert on any non-OK result — chain breaks from renewals or CA rotations should page within 60 seconds, not after user reports.
x509 Unknown Authority — How to Confirm and Fix Each Cause
Root CauseHow to ConfirmFixPrevention
Server omits the intermediate (leaf-only file)s_client -showcerts shows 1 cert; browsers pass but Go/Java/curl failServe fullchain.pem and reload the terminatorPost-renewal gate asserting served depth >= 2
Private CA root missing from OS storeopenssl verify with system bundle says 'unable to get local issuer'Install root into anchors dir and run update-ca-certificates/update-ca-trustBake the root into base images and verify in CI
Java ignores the OS store (uses cacerts only)System curl passes while the JVM service still throws PKIX errorskeytool -import the root into cacerts on every JVM host/imageAdd cacerts import to JVM image builds with a CI assertion
Node or Python reads a bundled store, not the OS oneNODE_EXTRA_CA_CERTS unset, or requests fails while urllib passesSet NODE_EXTRA_CA_CERTS; pin REQUESTS_CA or upgrade certifiStandardize the env vars and certifi version in deploy manifests
Verification disabled as a 'fix' (InsecureSkipVerify/verify=False)Grep finds skip flags in code or env (NODE_TLS_REJECT_UNAUTHORIZED=0)Remove every skip flag and fix the chain or store insteadLint CI for skip-verify patterns and fail the build on match
⚙ Quick Reference
6 commands from this guide
FileCommand / CodePurpose
openssl s_client -connect api.example.com:443 -servername api.example.com -showc...How Chain Building Fails
etcnginxsites-availableapi.confserver {Serve the Full Chain
sudo cp corp-root-ca.crt /usr/local/share/ca-certificates/corp-root-ca.crtPrivate CAs
sudo keytool -import -trustcacerts -alias corp-root-ca \Language Stores
curl --cacert /tmp/candidate-bundle.crt https://internal.example.com/health -s -...Curl, Python, and Go
for host in api.example.com internal.example.com registry.example.com; doPrevention

Key takeaways

1
Unknown authority means a broken chain or an untrusted root
never dates or hostnames.
2
Browsers forgive missing intermediates; Go, Java, Python, and curl don't.
3
Serve fullchain.pem everywhere and gate renewals on served depth >= 2.
4
Install private roots into every store
OS bundle, cacerts, Node env, certifi.
5
Probe per-runtime with one-liners before fixing; test bundles with --cacert first.
6
Skipping verification hides the error and ships a MITM hole
fix the trust instead.

Common mistakes to avoid

5 patterns
×

Testing chain health with a browser instead of openssl

Symptom
Chrome shows green while every service client fails, delaying the real diagnosis by tens of minutes.
Fix
Always verify with openssl s_client -showcerts plus strict openssl verify — browsers cache and fetch intermediates that strict clients never will.
×

Pointing ssl_certificate at cert.pem instead of fullchain.pem

Symptom
Renewals periodically break Go/Java/curl clients while browsers stay green, in a pattern that looks random.
Fix
Use fullchain.pem on every terminator and add a renewal-hook gate that fails the deploy when served depth drops below 2.
×

Installing the private root only into the OS store on JVM hosts

Symptom
curl passes on the box but Java services keep throwing PKIX path-building errors after the 'fix'.
Fix
Import the root with keytool into cacerts too — the JVM never reads the OS bundle, so OS-only installation is invisible to Java.
×

Setting NODE_TLS_REJECT_UNAUTHORIZED=0 or verify=False to stop the pages

Symptom
Errors stop but traffic is now interceptable; a later audit flags plaintext-equivalent exposure on internal APIs.
Fix
Remove every skip flag, install the proper root into the runtime's store, and add a CI lint that fails builds containing skip-verify patterns.
×

Forgetting containers need the root baked into the image

Symptom
The app passes on the VM but fails in the pod with identical code and config, confusing two on-call shifts.
Fix
COPY the root into the Dockerfile and run update-ca-certificates at build time, then assert its presence with openssl verify in CI.
INTERVIEW PREP · PRACTICE MODE

Interview Questions on This Topic

Q01JUNIOR
Clients report 'x509: certificate signed by unknown authority' but brows...
Q02SENIOR
You install a private CA into the OS store but the Java service still fa...
Q03SENIOR
How do you prove a chain problem is server-side versus client-side with ...
Q04SENIOR
Why is InsecureSkipVerify / verify=False never an acceptable fix, even t...
Q05SENIOR
Design monitoring that would have caught a leaf-only renewal before any ...
Q01 of 05JUNIOR

Clients report 'x509: certificate signed by unknown authority' but browsers accept the site. What's happening?

ANSWER
The server is almost certainly sending the leaf without its intermediate. Browsers forgive this via cached intermediates and AIA fetching, but Go, Java, Python, and curl build chains strictly from what's served plus local roots. Confirm with openssl s_client -showcerts (expect 2+ certs) and fix by serving fullchain.pem.
FAQ · 6 QUESTIONS

Frequently Asked Questions

01
How is 'unknown authority' different from an expired certificate?
02
Why does it work in Chrome but fail in curl and my Go service?
03
Where do I install a private CA so everything trusts it?
04
Is it safe to use InsecureSkipVerify just until the real fix ships?
05
How can I test a candidate CA bundle without installing it fleet-wide?
06
Our corporate proxy re-signs everything. Why does even google.com fail verification?
N
Naren Founder & Principal Engineer

20+ years shipping production infrastructure and CI/CD at scale. Lessons pulled from things that broke in production.

Follow
Verified
production tested
September 23, 2026
last updated
1,905
articles · all by Naren
🔥

That's Networking. Mark it forged?

6 min read · try the examples if you haven't

Previous
HTTP 413 Request Entity Too Large Fix
3 / 4 · Networking
Next
Nginx 504 Gateway Timeout Fix