x509: certificate signed by unknown authority

Your browser trusts the certificate and your application does not. They use different trust stores, and inside a container there may not be one at all.

Share
x509: certificate signed by unknown authority. Abstract networking illustration in orange and dark grey on debugly.dev

The short answer

x509: certificate signed by unknown authority
SSL: CERTIFICATE_VERIFY_FAILED
unable to get local issuer certificate

The client cannot build a chain from the server's certificate to a CA it trusts. Four causes:

  1. No CA bundle in the container. Scratch and some minimal images ship none.
  2. The server did not send intermediate certificates. Browsers paper over this, most clients do not.
  3. A corporate TLS-inspecting proxy with a private root CA.
  4. A genuinely self signed certificate.

Diagnose:

openssl s_client -connect api.example.com:443 -showcerts </dev/null 2>&1 | head -40

Read the Verify return code at the bottom and count the certificates in the chain.

Tested on OpenSSL 3.4.

Why the browser works and your app does not

Different trust stores, and different tolerance for a broken chain.

Browsers ship their own CA list and, crucially, fetch missing intermediates. Most browsers cache intermediate certificates from previous visits and can fetch them via the Authority Information Access extension.

Command line clients and language runtimes generally do neither. If the server does not send the full chain, they fail.

That is why "it works in Chrome" is not evidence that the server is configured correctly. It usually means Chrome compensated.

Cause 1: no CA bundle in the container

FROM scratch
COPY --from=build /out/server /server

A scratch image has no filesystem. No /etc/ssl/certs, no CA bundle, so every TLS connection to a public endpoint fails.

Same for some minimal Alpine builds where ca-certificates was never installed.

# alpine
RUN apk add --no-cache ca-certificates

# debian slim
RUN apt-get update && apt-get install -y --no-install-recommends ca-certificates \
    && rm -rf /var/lib/apt/lists/*

# scratch: copy the bundle from a build stage
FROM alpine:3.21 AS certs
RUN apk add --no-cache ca-certificates

FROM scratch
COPY --from=certs /etc/ssl/certs/ca-certificates.crt /etc/ssl/certs/
COPY --from=build /out/server /server

Distroless images include CA certificates, which is one of several reasons to prefer them over scratch.

Verify inside the container:

docker run --rm myimage ls -la /etc/ssl/certs/ca-certificates.crt

If that file is missing, you have your answer and no amount of application configuration will fix it.

Cause 2: an incomplete chain from the server

A certificate chain is leaf, then one or more intermediates, then a root. The server must send the leaf and the intermediates. The root is in the client's trust store.

openssl s_client -connect api.example.com:443 -showcerts </dev/null 2>&1 | grep -c "BEGIN CERTIFICATE"

One certificate means only the leaf was sent, and that is a server misconfiguration.

Verify return code: 21 (unable to verify the first certificate)

The fix is on the server. Most CAs provide a fullchain file, and the common mistake is deploying cert.pem instead of fullchain.pem:

ssl_certificate     /etc/letsencrypt/live/example.com/fullchain.pem;   # correct
ssl_certificate_key /etc/letsencrypt/live/example.com/privkey.pem;

If it is somebody else's server, report it. As a temporary measure you can add the intermediate to your own trust store, which is unpleasant and better than disabling verification.

Check any endpoint you depend on with an SSL test tool. A missing intermediate frequently goes unnoticed for months because browsers hide it, and then breaks the first non-browser client that connects.

Cause 3: a corporate proxy

Verify return code: 19 (self signed certificate in certificate chain)

and the issuer is something like CN=Acme Corp Root CA.

Your traffic is being intercepted and re-signed by a TLS inspection proxy. Your laptop trusts the corporate root because IT installed it in the OS store. Your container and your language runtime do not.

Add the corporate root to the image:

COPY corp-root-ca.crt /usr/local/share/ca-certificates/
RUN update-ca-certificates

For Alpine, the same path works after installing ca-certificates.

Note that several runtimes ignore the system store by default:

Node uses its own bundled CA list. Point it at the system store or add extra certificates:

NODE_EXTRA_CA_CERTS=/usr/local/share/ca-certificates/corp-root-ca.crt node app.js

Python requests uses certifi, which is a bundled list:

REQUESTS_CA_BUNDLE=/etc/ssl/certs/ca-certificates.crt python app.py
SSL_CERT_FILE=/etc/ssl/certs/ca-certificates.crt python app.py

Go uses the system store on Linux, so update-ca-certificates is sufficient.

Java uses its own keystore and needs keytool -importcert.

This asymmetry is why a fix that works for one service in your stack does not work for the next one.

Cause 4: a genuinely self signed certificate

Internal services, development environments, a private registry.

The correct fix is to trust the specific certificate, not to disable verification:

import { readFileSync } from "node:fs";
import { Agent } from "undici";

const agent = new Agent({
  connect: { ca: readFileSync("/etc/ssl/internal-ca.crt") },
});
requests.get(url, verify="/etc/ssl/internal-ca.crt")

Do not disable verification. rejectUnauthorized: false, verify=False, InsecureSkipVerify: true, and curl -k all turn off the protection entirely, which means any machine on the path can impersonate the endpoint. It is the fastest way to make the error go away and it converts a configuration problem into a security hole, and it invariably survives into production because nobody remembers to remove it.

If you find one of these in a codebase, treat it as a finding rather than a style issue.

Reading the openssl output

openssl s_client -connect api.example.com:443 -servername api.example.com </dev/null

-servername sets SNI, and omitting it on a host serving multiple certificates gives you the wrong one and a confusing mismatch error.

The bottom of the output:

Verify return code: 0 (ok)

Common codes:

Code Meaning
0 Verified
10 Certificate has expired
18 Self signed certificate
19 Self signed certificate in chain, usually a proxy
20 Unable to get local issuer, missing intermediate or missing CA bundle
21 Unable to verify the first certificate, incomplete chain

Check expiry directly:

echo | openssl s_client -connect api.example.com:443 2>/dev/null \
  | openssl x509 -noout -dates -subject -issuer

Expiry, which is its own category

A certificate that expires at 3am produces this error across every client at once, and the cause is not a code change so nobody looks at TLS first.

Monitor expiry as a metric, not as a calendar reminder:

expiry=$(echo | openssl s_client -connect example.com:443 2>/dev/null \
  | openssl x509 -noout -enddate | cut -d= -f2)
days=$(( ($(date -d "$expiry" +%s) - $(date +%s)) / 86400 ))
echo "days_until_cert_expiry $days"

Alert at 30 days and at 7. This belongs in the same category as alerting on the absence of expected events: a certificate quietly approaching expiry generates no signal until it generates a total outage.

Check internal and client certificates too. Automated renewal covers public endpoints, and the ones that catch people are internal mTLS certificates and client certificates for third party integrations, which are frequently issued manually and forgotten for a year.