skip to content

Verify on a customer's PEM chain returns 'certificate signed by unknown authority' — how do you find the missing link?

level: seniorimportance: should knowfreq 38%

answer

  1. print before you theorise
  2. one line per hop on the screen
  3. issuer of one, subject of the next
  4. browsers hide what Go refuses to fetch
  5. prove it by adding the link and re-verifying

basics

~20 s

Split the bundle into PEM blocks, parse each, and print Subject, Issuer, NotAfter and DNSNames. The break is where a certificate's Issuer matches no other's Subject and no trusted root; Go never fetches a missing issuer.

solid answer

~50 s

Make the chain visible before theorising. Loop `pem.Decode` over the bundle, `x509.ParseCertificate` each `CERTIFICATE` block, and print for every hop its `Subject`, `Issuer`, `NotBefore`/`NotAfter`, `IsCA` and `DNSNames`. Then walk the list: each certificate's `Issuer` should equal the next one's `Subject`, and the last one's issuer should be a root in your pool. Wherever that stops matching is the missing link — overwhelmingly the server omitted its intermediate, which browsers hide because they cache or fetch intermediates while Go's verifier builds paths only from the pools you supply. Confirm rather than guess: add the suspected intermediate to `opts.Intermediates` and re-run `Verify`. Also rule out the neighbours — use `errors.As` to separate `UnknownAuthorityError` from `CertificateInvalidError` with `Reason == x509.Expired` and from `HostnameError`, and check the bundle actually decoded, since a missing newline between an END and the next BEGIN line makes later blocks invisible to `pem.Decode`.

code

go · 25 lines
go
var certs []*x509.Certificate
for rest := bundle; len(rest) > 0; {
	var block *pem.Block
	block, rest = pem.Decode(rest)
	if block == nil {
		break
	}
	if block.Type != "CERTIFICATE" {
		continue
	}
	c, err := x509.ParseCertificate(block.Bytes)
	if err != nil {
		return err
	}
	certs = append(certs, c)
	fmt.Printf("subject=%q issuer=%q ca=%t notAfter=%s dns=%v\n",
		c.Subject.CommonName, c.Issuer.CommonName, c.IsCA,
		c.NotAfter.Format(time.RFC3339), c.DNSNames)
}
for i := 0; i+1 < len(certs); i++ {
	if certs[i].Issuer.CommonName != certs[i+1].Subject.CommonName {
		fmt.Printf("chain breaks after hop %d: nothing here signs %q\n",
			i, certs[i].Subject.CommonName)
	}
}

go deeper

for a junior

Know that this error means no path to a trusted root was found, not that the certificate is broken, and that the first useful step is printing each certificate's subject and issuer.

for a middle

Explain the link property you are testing — each certificate's Issuer against the next one's Subject — and why Go fails where a browser succeeds: path building uses only the supplied pools, with no network fetch.

for a senior

Show the discipline of confirming rather than guessing: add the suspected intermediate to the options, re-verify, and separate unknown-authority from expiry and hostname errors with errors.As before naming a cause to the customer.

for a principal

Push the diagnosis upstream: a startup log line per loaded certificate with its subject and expiry, and an expiry alert, so the next occurrence is caught before a customer is on the call rather than reconstructed during one.

## The error means exactly one thing `x509: certificate signed by unknown authority` means the verifier could not build a path from the leaf to any certificate in `opts.Roots`. It does not mean the certificate is bad, expired, or for the wrong host — those are different error types. So the entire investigation is: *which hop could not be linked, and why*. ## Step 1: print the chain With a customer on a shared screen, the fastest move is a small terminal utility that turns the bundle they pasted into readable lines. Decode every PEM block, parse each `CERTIFICATE`, and print per hop: - `Subject` — who this certificate is - `Issuer` — who signed it - `NotBefore` / `NotAfter` — its window - `IsCA` — whether it may sign others - `DNSNames` — the names the leaf covers Half of these incidents end here, because the printout shows something the customer did not expect: two certificates, not three; a leaf whose `NotAfter` was last week; a bundle in the wrong order; or a `DNSNames` list that does not contain the host they are dialling. ## Step 2: walk the links A well-formed chain satisfies one property: hop *n*'s `Issuer` equals hop *n+1*'s `Subject`, and the last hop's `Issuer` is the subject of a root in your pool (a self-signed root has `Subject == Issuer`). Find the first index where that fails. Two shapes account for nearly all cases: 1. **The intermediate is absent.** The bundle holds only the leaf, whose issuer is a CA you have never heard of. The server was configured with the certificate but not the chain file. This is the classic "works in my browser" case: browsers cache intermediates seen on earlier connections and can fetch a missing issuer, while **Go's verifier never goes to the network** — it uses `Roots` and `Intermediates` and nothing else. A Go client is therefore the first thing to break on a misconfigured server, and it is right to. 2. **The anchor is not really an anchor.** The customer pasted what they call "the root", but its `Subject` and `Issuer` differ — it is an intermediate. Loading it into `Roots` would appear to fix things while silently making it a trust anchor, which is the wrong repair; the correct one is to obtain the actual root and put the intermediate in `Intermediates`. ## Step 3: check the bundle even decoded Before blaming the PKI, confirm you are looking at everything the file contains. `pem.Decode` returns a nil block for input it does not recognise, with no error, so a hand-assembled bundle where an `-----END CERTIFICATE-----` and the following `-----BEGIN CERTIFICATE-----` ended up on the same line silently yields one certificate instead of three. If your loader used `AppendCertsFromPEM`, its `true` return would not have told you either — it reports that *something* parsed, not that everything did. Printing a count is what makes this visible. ## Step 4: confirm by construction A hypothesis is not a diagnosis. Put the suspected intermediate into an `x509.CertPool`, pass it as `opts.Intermediates`, keep the same `Roots`, and re-run `Verify`. Success proves the missing link was the cause and tells the customer exactly which certificate their server must send. Failure sends you back to step 2 with better information. ## Step 5: separate the neighbouring failures Run the errors through `errors.As` so the label is precise: - `x509.UnknownAuthorityError` — path building failed; this article's case. - `x509.CertificateInvalidError` with `Reason == x509.Expired` — a hop is outside its validity window. Check every hop, not just the leaf; an expired intermediate is easy to miss because the leaf looks fine. - `x509.HostnameError` — trust is fine, the name is not. Remember that modern Go matches only the SANs in `DNSNames`/`IPAddresses`; a certificate identified only by its Subject CommonName fails here regardless of how a certificate viewer displays it. ## Making the next one shorter The durable fix is a startup log line from the service itself: for every certificate it loads, one line with the subject and `NotAfter`. It costs nothing, it is in the logs before the incident, and it converts "unknown authority, cause unknown" into "we load two hops and the chain needs three" without anyone pasting a bundle into a chat window.

  • The customer insists the same server works in their browser. What do you tell them?
    Browsers are more forgiving: they cache intermediates seen on earlier connections and can fetch a missing issuer, so a server that omits its chain works for a user who visited a sibling site first. Go's verifier builds paths only from the pools you give it and never goes to the network, so it fails immediately and deterministically. The server configuration is still wrong; Go just noticed first.
  • How do you rule out expiry rather than a missing link before going further?
    Use `errors.As` for `x509.CertificateInvalidError` and compare its `Reason` against `x509.Expired`. Then check `NotAfter` on every hop rather than only the leaf — an expired intermediate is easy to overlook because the leaf that everyone is looking at is perfectly valid. Passing a future `CurrentTime` in the options also answers whether the chain survives the next sixty days.
  • Why is loading the customer's intermediate into Roots the wrong repair even though it makes the error disappear?
    It promotes that intermediate to a trust anchor, so everything it ever signed becomes trusted by your service and the real root's constraints are never evaluated. The correct fix is on their side — the server must send its chain — or, if you must hold it locally, put the intermediate in `Intermediates` and keep the genuine root as the only anchor.

saying these in an interview costs you the question

  • Adds the intermediate to Roots to make the error go away
  • Assumes the certificate is expired without checking the error type
  • Trusts that the bundle decoded without counting the blocks
  • Expects Go to fetch the missing issuer as a browser might
  • Checks NotAfter only on the leaf and never on the intermediates
  • Concludes the customer's certificate is invalid rather than incomplete