Skip to main content
DevOps Insightsguides & answers

TLS · troubleshooting

x509: certificate signed by unknown authority

Your client does not trust the server's certificate: Go says signed by unknown authority, curl and OpenSSL say unable to get local issuer certificate. Three different situations produce nearly identical wording — a private CA it has never heard of, a server sending an incomplete chain, or a proxy substituting its own certificate — and openssl s_client tells them apart in one command.

Reproduced on kubectl 1.33.1, . Where the text says “reproduced”, the behaviour was run on that version, not taken from documentation.

First published

New to this? Read what is actually happening first, then come back to the steps.

On this page · 9 sections

Before you start

  • The exact hostname and port the failing client connects to, and a shell on the same machine or container image where it fails. Each image and each client can carry its own trust store.
  • openssl on that machine, to see the certificates the server actually sends with openssl s_client.
  • To fix a missing chain: access to the server's TLS configuration, such as the file nginx's ssl_certificate points at, and permission to reload the server.
  • To trust a private CA: its certificate in PEM format, obtained from whoever runs the CA, plus root on the client to run update-ca-certificates (Debian and Ubuntu) or to write /etc/docker/certs.d/ for Docker.
  • For kubectl: the cluster's CA certificate, which goes in the kubeconfig's certificate-authority setting or the --certificate-authority flag.

Run this first

step 1 of 3
openssl s_client -connect <host>:443 -servername <host> </dev/null 2>/dev/null | grep -E '^ *([0-9]+ )?[si]:|Verify return code'

Look for How many certificates are in the chain, who issued them, and the Verify return code

Shows the chain the server actually sent and the verification result. One certificate with no issuer above it points at an incomplete chain; an unfamiliar issuer points at a private CA or a proxy.

Work out which cause you have

A few questions to narrow the list. Every answer ends in a command that confirms or rules the cause out — this cannot see your cluster, so nothing here is a certainty until you have checked.

Narrow it down

0 answered · nothing is sent anywhere

Does a well-known public site — say a package registry — fail the same way from this machine?

openssl s_client -connect github.com:443 -servername github.com </dev/null | openssl x509 -noout -issuer

Cause space

5 of 5 still possible

  • The server is not sending its intermediate certificateCommon
  • The certificate was issued by a private CA the client does not knowCommon
  • A TLS-inspecting proxy is substituting its own certificateOccasional
  • The certificate is expired, or not valid for this hostnameOccasional
  • The client has no usable CA bundleOccasional

Nothing ruled out yet. Answer the question above and the branches your answer eliminates will strike through here.

Or diagnose it manually

In this order. The first command usually contains the whole answer.

  1. Step 1

    openssl s_client -connect <host>:443 -servername <host> </dev/null 2>/dev/null | grep -E '^ *([0-9]+ )?[si]:|Verify return code'

    Shows the chain the server actually sent and the verification result. One certificate with no issuer above it points at an incomplete chain; an unfamiliar issuer points at a private CA or a proxy.

    Look for How many certificates are in the chain, who issued them, and the Verify return code

  2. Step 2

    openssl s_client -connect <host>:443 -servername <host> </dev/null 2>/dev/null | openssl x509 -noout -dates -subject -issuer

    Separates expiry and naming from trust. These produce similar messages and have unrelated fixes.

    Look for notBefore/notAfter around today, and a subject matching the host you asked for

  3. Step 3

    curl -v https://<host> 2>&1 | grep -iE 'CAfile|CApath|SSL certificate|subject|issuer'

    Confirms which trust store this client is actually reading, which is frequently not the one you edited.

    Look for The CAfile path, and whether it is the store you added the CA to

Every cause, and how to fix it

Ordered by how often each one turns out to be the answer.

The server is not sending its intermediate certificate

Common

The server presents only its leaf certificate. The client holds the root but has no way to connect the two, because the intermediate that signs the leaf was never sent. Browsers often recover because they have already stored that intermediate from another site. Command-line clients such as curl, Python and Go programs fail, so the same URL can work in a browser and fail in a container.

Confirm

openssl s_client -connect <host>:443 -servername <host> </dev/null 2>/dev/null | grep -E '^ *([0-9]+ )?[si]:|Verify return code'

A chain with only one certificate ( 0 s:) and no 1 s: above it, plus a non-zero Verify return code

Fix

  • Fix it on the server: configure it to serve the full chain rather than the leaf alone. For nginx this means ssl_certificate pointing at a file containing the leaf followed by the intermediates — Let's Encrypt's fullchain.pem, not cert.pem.
  • This is worth fixing at the source even when a client-side workaround exists, because every other client will hit it too.
openssl s_client -connect <host>:443 -servername <host> -showcerts </dev/null 2>/dev/null | grep -c 'BEGIN CERTIFICATE'

One certificate usually means the chain is short. Two or more is normal for a publicly trusted site.

The certificate was issued by a private CA the client does not know

Common

Internal registries, staging environments and service meshes often use a CA generated in-house. It is a perfectly valid certificate; the client simply has no reason to trust that issuer. Containers make this common, because a minimal image carries a small root store and none of your organisation's additions.

Confirm

openssl s_client -connect <host>:443 -servername <host> </dev/null 2>/dev/null | openssl x509 -noout -issuer

An issuer that is your own organisation rather than a public CA

Fix

  • Add the CA certificate to the client's trust store rather than disabling verification. In a Debian-based image: copy the CA, in PEM format with a .crt extension, into /usr/local/share/ca-certificates/ and run update-ca-certificates. On Alpine: /usr/local/share/ca-certificates/ then update-ca-certificates, with the ca-certificates package installed.
  • For Docker pulling from a registry with a private CA, the daemon reads certificates from /etc/docker/certs.d/<registry-host>:<port>/ca.crt (leave out :<port> when the registry uses the default port).
  • Resist --insecure, -k, GIT_SSL_NO_VERIFY and InsecureSkipVerify. They turn a trust problem into no transport security at all, and they have a way of reaching production.

A TLS-inspecting proxy is substituting its own certificate

Occasional

Corporate networks frequently terminate TLS at a proxy and re-issue certificates from an internal CA so traffic can be inspected. The certificate you are shown is the proxy's, which is why the issuer is unfamiliar even for a well-known public site. The give-away is that a public site such as a package registry fails in exactly the same way as an internal one.

Confirm

openssl s_client -connect github.com:443 -servername github.com </dev/null 2>/dev/null | openssl x509 -noout -issuer

An issuer that is your employer rather than a public CA, on a site that should certainly have a public certificate

Fix

  • Obtain the proxy's CA certificate from whoever runs it and install it in the client's trust store, as for a private CA.
  • In CI images this usually means baking the CA in at build time, so every job inherits it rather than each one working around it.

The certificate is expired, or not valid for this hostname

Occasional

A different failure that is easy to mistake for this one, because some tools report both under similar wording. An expired certificate chains correctly and is simply out of date. A hostname mismatch means the name you connected to is not in the certificate's subject alternative names — common when connecting by IP address, or to an internal name that the certificate does not list.

Confirm

openssl s_client -connect <host>:443 -servername <host> </dev/null 2>/dev/null | openssl x509 -noout -dates -subject -ext subjectAltName

A notAfter date in the past, or a SAN list that does not contain the name you used

Fix

  • Renew the certificate, or correct the name you are connecting to so it matches a SAN entry.
  • Where renewal is automated, check that the renewal ran and that the service was reloaded afterwards — a renewed file that nothing re-read is a common version of this.

The client has no usable CA bundle

Occasional

A complete chain from a public CA still fails if the client has no trust store to check it against, or one too old to include the root. Minimal container images often ship without a CA bundle, and a Go program in one finds no roots at all, so every TLS connection fails.

Confirm

ls -l /etc/ssl/certs/ca-certificates.crt   # the Debian, Ubuntu and Alpine path

A missing or empty file is the answer. If it exists, check whether the client is pointed at a different CA file (a CAfile or SSL_CERT_FILE setting)

Fix

  • Install the distribution's ca-certificates package in the image and rebuild.
  • If the client is configured with its own CA file, make sure that file also contains the public roots.

Understanding it properly

Skip this if you are mid-incident — the working part of the page is above. Worth reading afterwards, because understanding the mechanism is what stops the next one.

In plain terms

Your program refused to talk to a server because it could not confirm who issued the server's certificate. A certificate has to lead back, through a chain of signatures, to an authority your machine already trusts. This error means that chain was missing a link, or led to an authority your machine has never been told to trust.

One untrusted certificate, three clients

Reproduced with kubectl 1.33.1, curl 8.7.1 and OpenSSL 3.6.4 on macOS: a throwaway self-signed certificate for localhost and 127.0.0.1 (the loopback address), served with openssl s_server -accept 18443 -www. kubectl ran with an empty kubeconfig and a placeholder token so it would not stop to ask for a username. kubectl printed the memcache.go line five times; one is shown.

Command

kubectl --server=https://127.0.0.1:18443 --token=placeholder get pods
curl -sS https://127.0.0.1:18443/
openssl s_client -connect 127.0.0.1:18443 -servername localhost </dev/null 2>/dev/null | grep -E '^ *([0-9]+ )?[si]:|Verify return code'

Output

$ kubectl --server=https://127.0.0.1:18443 --token=placeholder get pods
E1012 13:28:18.898317   40123 memcache.go:265] "Unhandled Error" err="couldn't get current server API group list: Get \"https://127.0.0.1:18443/api?timeout=32s\": tls: failed to verify certificate: x509: certificate signed by unknown authority"
Unable to connect to the server: tls: failed to verify certificate: x509: certificate signed by unknown authority

$ curl -sS https://127.0.0.1:18443/
curl: (60) SSL certificate problem: self signed certificate
More details here: https://curl.se/docs/sslcerts.html

curl failed to verify the legitimacy of the server and therefore could not
establish a secure connection to it. To learn more about this situation and
how to fix it, please visit the web page mentioned above.

$ openssl s_client -connect 127.0.0.1:18443 -servername localhost </dev/null 2>/dev/null | grep -E '^ *([0-9]+ )?[si]:|Verify return code'
 0 s:CN=localhost
   i:CN=localhost
Verify return code: 18 (self-signed certificate)

How to read it

  • kubectl is a Go program, so it uses Go's wording, x509: certificate signed by unknown authority, after the prefix tls: failed to verify certificate:. It retries API discovery, which is why the same line repeats before Unable to connect to the server.
  • curl names the same refusal self signed certificate. This curl is built on LibreSSL; OpenSSL 3 spells it self-signed, so search for both spellings.
  • openssl s_client shows the cause: one certificate ( 0 s:) whose issuer (i:) is its own name, and Verify return code: 18. Serving only a leaf signed by a private intermediate gave 21 (unable to verify the first certificate), while curl said unable to get local issuer certificate for both a missing intermediate and an untrusted private CA. Counting the certificates is what separates them.
  • The server never sees an HTTP request. openssl s_server logged a TLS alert for each attempt: alert bad certificate for kubectl and alert unknown ca for curl and Python.
  • On macOS, Go hands verification to the system. A complete chain from an untrusted private CA came back from kubectl as x509: “Throwaway Intermediate CA” certificate is not trusted instead. Same cause, different words.

Words used on this page

Certificate
A file a server presents to prove its name. It holds the name, a public key, validity dates and the signature of whoever issued it.
Certificate authority (CA)
An issuer whose signature clients accept. Public CAs come preinstalled. A private CA is one your organisation runs, and clients trust it only after you add it.
Trust store
The set of CA certificates a client trusts, for example /etc/ssl/certs/ca-certificates.crt on Debian. Different clients on one machine can read different stores.
Leaf certificate
The server's own certificate, listed first in the chain ( 0 s: in openssl s_client output).
Intermediate certificate
A CA certificate that sits between the leaf and a root. The server is expected to send it, because clients usually do not have it.
Chain
The leaf plus the intermediates the server sends, in order. Verification walks it from the leaf up to a root in the trust store.
Self-signed certificate
A certificate whose issuer is itself. A client accepts it only if that exact certificate is in its trust store.
Verify return code
The result openssl s_client prints at the end. 0 (ok) means the chain checked out; any other number names the failure.
What is actually happening

Each tool words the same three situations differently. Reproduced on 21 Oct 2026 against the badssl.com test hosts with OpenSSL 4.0.3 and Node.js v25.9.0: a server that omits its intermediate gave verify code 21, unable to verify the first certificate, and Node's UNABLE_TO_VERIFY_LEAF_SIGNATURE; an untrusted root gave 19 and SELF_SIGNED_CERT_IN_CHAIN; a self-signed leaf gave 18 and DEPTH_ZERO_SELF_SIGNED_CERT; and a trust store that lacks the issuer gave 20, unable to get local issuer certificate, and UNABLE_TO_GET_ISSUER_CERT_LOCALLY, the same failure curl reports as curl: (60).

On the same Mac, curl, Python and Git built on Apple's TLS libraries succeeded against the incomplete chain and ignored the narrowed trust store. That is one reason a request works on macOS and fails in a Linux container: the container's OpenSSL checks the chain and CA bundle strictly.

TLS verification asks one question: does this certificate chain up to something I already trust? The client has a set of root certificates; the server presents its own certificate plus any intermediates needed to connect the two. If the client cannot walk that path to a root it holds, it refuses, and this is the refusal.

The wording varies by language and tool for the same underlying failure. Go says x509: certificate signed by unknown authority. curl and other OpenSSL-based clients say unable to get local issuer certificate, both when the server left out its intermediate and when the issuer is an untrusted private CA. openssl s_client adds unable to verify the first certificate as its final verdict when the server sent only its leaf. Python says CERTIFICATE_VERIFY_FAILED. Java says PKIX path building failed. They are all the same sentence.

The thing most worth knowing: this is usually not the server's certificate being invalid. It is often the chain being incomplete — the server sends its own certificate and omits the intermediate that links it to the root. Browsers often paper over this because they keep intermediates they have already received from other sites; curl, Go and Java generally do not, which is why a URL that works in a browser fails in a container.

A corporate TLS-inspecting proxy produces the identical error for a different reason: the certificate you are shown is genuinely not the server's, because the proxy terminated the connection and issued its own. There the fix is to trust the proxy's CA, not to work around verification.

How to tell this is your problem
WhereWhat you see
Go programs, kubectl, dockerx509: certificate signed by unknown authority
curlcurl: (60) SSL certificate problem: unable to get local issuer certificate
gitSSL certificate problem: self signed certificate in certificate chain (builds on OpenSSL 3 spell it self-signed)
A browser on the same URLFrequently works, which is the clue that the chain is incomplete rather than wrong
Node.js (npm, yarn)UNABLE_TO_GET_ISSUER_CERT_LOCALLY, UNABLE_TO_VERIFY_LEAF_SIGNATURE (unable to verify the first certificate), or SELF_SIGNED_CERT_IN_CHAIN
Python (requests, urllib, pip)[SSL: CERTIFICATE_VERIFY_FAILED] certificate verify failed: unable to get local issuer certificate
openssl s_clientVerify return code: 20 (unable to get local issuer certificate), 21 (unable to verify the first certificate), 19 or 18 (self-signed)

How to know it is actually fixed

  • openssl s_client reports Verify return code: 0 (ok).
  • The original command — curl, docker pull, kubectl, git — succeeds without any flag that disables verification.
  • A second client on the same machine, and a container built from the same image, both succeed; trust added only to one shell is not a fix.

Stopping it happening again

  • Serve the full chain, not the leaf alone. With Let's Encrypt that means fullchain.pem.
  • Bake internal CA certificates into base images rather than adding them per job, so no pipeline is tempted to disable verification instead.
  • Monitor expiry rather than waiting to be told. A renewal that ran but did not reload the service looks exactly like a renewal that never ran.
  • Treat --insecure and InsecureSkipVerify as findings in review, not conveniences. They remove the property TLS exists to provide.

Security considerations

  • Do not make the error go away with curl -k, --insecure-skip-tls-verify, GIT_SSL_NO_VERIFY or Go's InsecureSkipVerify. Go's documentation says that mode accepts any certificate and any host name and leaves the connection open to machine-in-the-middle attacks.
  • Get a CA certificate from its owner over a channel you already trust, and compare its fingerprint (openssl x509 -noout -fingerprint -sha256 -in ca.crt) before installing it. Saving whatever the server presents with -showcerts trusts whoever answered, including an attacker in the path.
  • A CA added to the system store is trusted for every site, not only the one that failed. Where the client allows it, scope the trust instead: kubectl's certificate-authority, Docker's per-registry /etc/docker/certs.d/<host:port>/ca.crt, or git's http.sslCAInfo.
  • If a public site fails and openssl s_client shows an issuer you do not recognise instead of the site's public CA, something in the middle of the connection issued that certificate. Confirm with whoever runs the network that it is their inspection proxy before you trust its CA, because trusting it lets that proxy read and alter your TLS traffic.

Sources

Behaviour described here is drawn from official documentation. Where a figure could not be confirmed on an official page it is attributed in the text rather than stated as canonical.

Did this get you to an answer?

No text box on purpose — please do not paste production logs anywhere

Something wrong or out of date? Report it