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 see | Cause | Where to fix it |
|---|---|---|
| One certificate from a public CA, works in the browser | The server does not send its intermediate | The server |
| Chain looks fine, fails only in your container | The image has no root certificates | Your Dockerfile |
| Issuer is your company CA or a proxy like Zscaler | Private CA or TLS inspection | Your client’s trust pool |
| Subject and issuer are identical | Self-signed certificate | Your 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.