Skip to main content
x509: certificate signed by unknown authority in Go: the four causes and how to fix each
Engineering Sep 27, 2026

x509: certificate signed by unknown authority in Go: the four causes and how to fix each

Your Go service calls an HTTPS API and dies with this:

Get "https://api.example.com": tls: failed to verify certificate: x509: certificate signed by unknown authority

The URL works in Chrome, and the same binary works on your Mac. It only fails in the Linux container, in CI, or on a customer’s server. That pattern usually points at a misconfigured server, not at your code.

Go could not build a chain from the certificate it received to a root it trusts. There are four common reasons, each with its own fix. InsecureSkipVerify: true makes all four go away, and it is the one fix you should not ship.

Find out which cause you have

Look at what the server sends:

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

Count the BEGIN CERTIFICATE blocks and read the s: (subject) and i: (issuer) lines:

What you seeCauseWhere to fix it
One certificate from a public CA, works in the browserThe server does not send its intermediateThe server
Chain looks fine, fails only in your containerThe image has no root certificatesYour Dockerfile
Issuer is your company CA or a proxy like ZscalerPrivate CA or TLS inspectionYour client’s trust pool
Subject and issuer are identicalSelf-signed certificateYour client’s trust pool, or replace the cert

Cause 1: the server sends only the leaf certificate

This is the most common cause, and the most confusing, because every check you run says the site is fine.

A server should send its own certificate plus the intermediate that signed it. When the intermediate is missing, browsers download it from the URL embedded in the certificate, and so do the macOS and Windows system verifiers that Go uses on those platforms. Go on Linux uses its own verifier, which fetches nothing. Here is incomplete-chain.badssl.com, a host that sends only the leaf, with the same code on both systems:

Go on macOS:                  200 OK
Go on Linux (Docker, arm64):  x509: certificate signed by unknown authority

The fix belongs on the server. With Let’s Encrypt, serve fullchain.pem, never cert.pem:

// fullchain.pem = leaf + intermediate. cert.pem alone breaks Go clients on Linux.
log.Fatal(http.ListenAndServeTLS(":443", "fullchain.pem", "privkey.pem", handler))

The same goes for nginx (ssl_certificate .../fullchain.pem;) and anything else that takes a certificate file. We tested a Go server both ways: cert.pem fails, fullchain.pem returns 200.

If you don’t run the server, report it to whoever does. Trusting the intermediate on your side (see cause 3) works as a stopgap until they renew with a different intermediate, which Let’s Encrypt does routinely. Node has the same problem with a different error message, covered in unable to verify the first certificate in Node.js.

To reproduce Linux behavior on a Mac without Docker, build the pool with x509.NewCertPool() and load /etc/ssl/cert.pem into it. That forces Go’s own verifier.

Cause 2: your container has no root certificates

FROM scratch has no /etc/ssl/certs, so every HTTPS call fails with the same error. Some slim images have the same problem until you install ca-certificates. Copy the bundle from your build stage:

FROM golang:1.26 AS build
WORKDIR /src
COPY . .
RUN CGO_ENABLED=0 go build -o /app .

FROM scratch
COPY --from=build /etc/ssl/certs/ca-certificates.crt /etc/ssl/certs/
COPY --from=build /app /app
ENTRYPOINT ["/app"]

Or compile the roots into the binary with the Go team’s bundle:

import _ "golang.org/x/crypto/x509roots/fallback"

Go only uses these roots when the system has none. They are frozen at build time, though, so a binary that sits untouched for two years is better served by the Dockerfile approach. We built all three variants: no bundle fails, and both fixes return 200.

On Linux you can also point Go at a bundle with SSL_CERT_FILE or SSL_CERT_DIR. macOS and Windows ignore them.

Cause 3: a private CA or a TLS-inspecting proxy

The chain is complete but ends at a root Go has never heard of, either your company’s CA or a proxy that re-signs HTTPS traffic. Add that root to the system pool:

pool, err := x509.SystemCertPool()
if err != nil {
	pool = x509.NewCertPool()
}
caPEM, err := os.ReadFile("/etc/myapp/internal-ca.pem")
if err != nil {
	log.Fatal(err)
}
if !pool.AppendCertsFromPEM(caPEM) {
	log.Fatal("no certificates found in internal-ca.pem")
}

client := &http.Client{
	Transport: &http.Transport{
		TLSClientConfig: &tls.Config{RootCAs: pool},
	},
}

Start from SystemCertPool(). A fresh NewCertPool() trusts your CA and nothing else, so every public API call breaks. And check the AppendCertsFromPEM result, because it returns false instead of an error on a bad file.

Cause 4: a self-signed certificate

For local development, mkcert creates a local CA that Go and your browser both trust. For a device you don’t control, load its certificate with the cause 3 code. For anything public, get a real certificate. Let’s Encrypt is free.

Why not InsecureSkipVerify?

&tls.Config{InsecureSkipVerify: true} // Don't.

The connection stays encrypted, but you no longer know who is on the other end. Anyone who can intercept the traffic can present their own certificate and your client will accept it. It also spreads, from a test helper into the production client, which is why gosec flags it as G402. Trusting one specific CA with the cause 3 code fixes the connection without turning verification off.

Find incomplete chains before your clients do

Cause 1 passes every browser check, so the first report usually comes from a customer’s Go or Java integration.

SSLBoard finds every hostname under your domain through Certificate Transparency logs, connects to each one, and flags certificate errors such as incomplete chains. Run a free scan and check the certificate errors section. Any host listed there fails for Go on Linux, whatever your browser says.