Below are two X.509 certificates. The first is the Certificate Authority (CA) root certificate, and the second is a leaf certifcate signed by the private key of the CA.

ca.crt.pem

-----BEGIN CERTIFICATE-----
MIIBejCCASGgAwIBAgIUda4UvlFzwQEO/fD0f4hAnj+ydPYwCgYIKoZIzj0EAwIw
EjEQMA4GA1UEAxMHUm9vdCBDQTAgFw0yNjAyMjcxOTQ3NDZaGA8yMTI2MDIwMzE5
NDc0NlowEjEQMA4GA1UEAxMHUm9vdCBDQTBZMBMGByqGSM49AgEGCCqGSM49AwEH
A0IABKL5BB9aaQ2TtNgUymEsa/+s2ZlTXVll0N22KKWxh0N/JdgHcjrKfzqRlVrt
UN2GXdvsdLOq15TxBq97WvE07lKjUzBRMB0GA1UdDgQWBBTAVEw9doSzY1DuPVxP
EnwEp/+VJDAfBgNVHSMEGDAWgBTAVEw9doSzY1DuPVxPEnwEp/+VJDAPBgNVHRMB
Af8EBTADAQH/MAoGCCqGSM49BAMCA0cAMEQCIHrSTk/KJHAjn3MC/egvfxMM1NpG
GEzMB7EH+VXWz7RfAiAyhwy4E9hc8/qsTI+4iKf2o/zMRu5H2GNJOLqOngglbQ==
-----END CERTIFICATE-----

leaf.crt.pem

-----BEGIN CERTIFICATE-----
MIIBHjCBxAIULE3hvnYxU91g9c9H3+uGCSqXi4MwCgYIKoZIzj0EAwIwEjEQMA4G
A1UEAwwHUm9vdCBDQTAgFw0yNjAyMjcxOTQ3NDZaGA8yMTI2MDIwMzE5NDc0Nlow
DzENMAsGA1UEAwwEbGVhZjBZMBMGByqGSM49AgEGCCqGSM49AwEHA0IABKDZ21Yh
+1AQp1TrxrS8FquIVEHrFRSXncX9xl5vVhZFqvblzTp2Tg7TER5x7rHG1TIqQL1z
xDX4TB+nZOWkyAcwCgYIKoZIzj0EAwIDSQAwRgIhAMeo5t2d1RWL/SB0E+mvvIZP
jFT0wDWX1Bm26MtxRcf9AiEApG96fs70WF1JliFgzkTiNvbG7Gj4SvErZ9nNX/Lr
PnA=
-----END CERTIFICATE-----

If you downloaded these certificates, you could visually see that the latter references the former as its Issuer. If you were to use a tool like openssl to verify that the leaf is signed by the private key of root, you would see that it is.

Unless of course you are reading this blog post from the year 2126 or you have changed the system time on your machine. If the former, I am exceedingly dissapointed that humanity is still using openssl.

openssl verify -CAfile ca.crt.pem leaf.crt.pem

Now, if you wanted to write a Go program that verified this chain of trust, it might look something like the following.

main.go

package main

import (
	"encoding/pem"
	"crypto/x509"
	"fmt"
	"os"
	"time"
)

func main() {
	b, err := os.ReadFile("ca.crt.pem")
	if err != nil {
		panic(err)
	}
	block, _ := pem.Decode(b)
	ca, err := x509.ParseCertificate(block.Bytes)
	if err != nil {
		panic(err)
	}
	b, err = os.ReadFile("leaf.crt.pem")
	if err != nil {
		panic(err)
	}
	block, _ = pem.Decode(b)
	lc, err := x509.ParseCertificate(block.Bytes)
	if err != nil {
		panic(err)
	}
	roots := x509.NewCertPool()
	roots.AddCert(ca)
	opts := x509.VerifyOptions{
		Roots:       roots,
		CurrentTime: time.Now(),
	}
	if _, err := lc.Verify(opts); err != nil {
		panic(err)
	}
    fmt.Println("Certificate verification successful.")
}

But if you ran that program, you might be surprised to see the following.

panic: x509: certificate signed by unknown authority

If you used this CA certificate instead, you would see the expected output.

ca.verifies.crt.pem

-----BEGIN CERTIFICATE-----
MIIBejCCASGgAwIBAgIUda4UvlFzwQEO/fD0f4hAnj+ydPYwCgYIKoZIzj0EAwIw
EjEQMA4GA1UEAwwHUm9vdCBDQTAgFw0yNjAyMjcxOTQ3NDZaGA8yMTI2MDIwMzE5
NDc0NlowEjEQMA4GA1UEAwwHUm9vdCBDQTBZMBMGByqGSM49AgEGCCqGSM49AwEH
A0IABKL5BB9aaQ2TtNgUymEsa/+s2ZlTXVll0N22KKWxh0N/JdgHcjrKfzqRlVrt
UN2GXdvsdLOq15TxBq97WvE07lKjUzBRMB0GA1UdDgQWBBTAVEw9doSzY1DuPVxP
EnwEp/+VJDAfBgNVHSMEGDAWgBTAVEw9doSzY1DuPVxPEnwEp/+VJDAPBgNVHRMB
Af8EBTADAQH/MAoGCCqGSM49BAMCA0cAMEQCIHrSTk/KJHAjn3MC/egvfxMM1NpG
GEzMB7EH+VXWz7RfAiAyhwy4E9hc8/qsTI+4iKf2o/zMRu5H2GNJOLqOngglbQ==
-----END CERTIFICATE-----
Certificate verification successful.

At first glance these certificates appear to be identical. You could use openssl to view the contents of both certificates, and you would get identical output.

openssl x509 -in ca.crt.pem -noout -text
Certificate:
    Data:
        Version: 3 (0x2)
        Serial Number:
            75:ae:14:be:51:73:c1:01:0e:fd:f0:f4:7f:88:40:9e:3f:b2:74:f6
        Signature Algorithm: ecdsa-with-SHA256
        Issuer: CN = Root CA
        Validity
            Not Before: Feb 27 19:47:46 2026 GMT
            Not After : Feb  3 19:47:46 2126 GMT
        Subject: CN = Root CA
        Subject Public Key Info:
            Public Key Algorithm: id-ecPublicKey
                Public-Key: (256 bit)
                pub:
                    04:a2:f9:04:1f:5a:69:0d:93:b4:d8:14:ca:61:2c:
                    6b:ff:ac:d9:99:53:5d:59:65:d0:dd:b6:28:a5:b1:
                    87:43:7f:25:d8:07:72:3a:ca:7f:3a:91:95:5a:ed:
                    50:dd:86:5d:db:ec:74:b3:aa:d7:94:f1:06:af:7b:
                    5a:f1:34:ee:52
                ASN1 OID: prime256v1
                NIST CURVE: P-256
        X509v3 extensions:
            X509v3 Subject Key Identifier: 
                C0:54:4C:3D:76:84:B3:63:50:EE:3D:5C:4F:12:7C:04:A7:FF:95:24
            X509v3 Authority Key Identifier: 
                C0:54:4C:3D:76:84:B3:63:50:EE:3D:5C:4F:12:7C:04:A7:FF:95:24
            X509v3 Basic Constraints: critical
                CA:TRUE
    Signature Algorithm: ecdsa-with-SHA256
    Signature Value:
        30:44:02:20:7a:d2:4e:4f:ca:24:70:23:9f:73:02:fd:e8:2f:
        7f:13:0c:d4:da:46:18:4c:cc:07:b1:07:f9:55:d6:cf:b4:5f:
        02:20:32:87:0c:b8:13:d8:5c:f3:fa:ac:4c:8f:b8:88:a7:f6:
        a3:fc:cc:46:ee:47:d8:63:49:38:ba:8e:9e:08:25:6d

However, if you were to compare the bytes of the certificates, you would see that there is a very slight difference; two bytes to be exact.

diff <(openssl x509 -in ca.crt.pem -outform der | xxd) <(openssl x509 -in ca.verifies.crt.pem -outform der | xxd)
4c4
< 00000030: 1231 1030 0e06 0355 0403 1307 526f 6f74  .1.0...U....Root
---
> 00000030: 1231 1030 0e06 0355 0403 0c07 526f 6f74  .1.0...U....Root
8c8
< 00000070: 1307 526f 6f74 2043 4130 5930 1306 072a  ..Root CA0Y0...*
---
> 00000070: 0c07 526f 6f74 2043 4130 5930 1306 072a  ..Root CA0Y0...*

In both locations, there is a 0x13 byte in the certificate which failed verification with the Go program (ca.crt.pem), and a 0x0c byte in the certificate that passed (ca.verifies.crt.pem). If you are familiar with X.509 certificates, you’ll know that they are defined using Abstract Syntax Notation One (ASN.1) and are binary encoded using Distinguished Encoding Rules (DER). They are frequently Base64 encoded and stored and transmitted as Privacy-enhanced Mail (PEM) text files (which you have already seen in this post).

The ASN.1 specification defines a set of data types, each with an associated tag (non-negative integer identifier), which precedes the length and the value when using DER encoding (see this post from Let’s Encrypt for more information). openssl can once again be used to see the data types of different fields in the certificate that is successfully verified.

openssl asn1parse -in ca.verifies.crt.pem
    0:d=0  hl=4 l= 378 cons: SEQUENCE          
    4:d=1  hl=4 l= 289 cons: SEQUENCE          
    8:d=2  hl=2 l=   3 cons: cont [ 0 ]        
   10:d=3  hl=2 l=   1 prim: INTEGER           :02
   13:d=2  hl=2 l=  20 prim: INTEGER           :75AE14BE5173C1010EFDF0F47F88409E3FB274F6
   35:d=2  hl=2 l=  10 cons: SEQUENCE          
   37:d=3  hl=2 l=   8 prim: OBJECT            :ecdsa-with-SHA256
   47:d=2  hl=2 l=  18 cons: SEQUENCE          
   49:d=3  hl=2 l=  16 cons: SET               
   51:d=4  hl=2 l=  14 cons: SEQUENCE          
   53:d=5  hl=2 l=   3 prim: OBJECT            :commonName
   58:d=5  hl=2 l=   7 prim: UTF8STRING        :Root CA
   67:d=2  hl=2 l=  32 cons: SEQUENCE          
   69:d=3  hl=2 l=  13 prim: UTCTIME           :260227194746Z
   84:d=3  hl=2 l=  15 prim: GENERALIZEDTIME   :21260203194746Z
  101:d=2  hl=2 l=  18 cons: SEQUENCE          
  103:d=3  hl=2 l=  16 cons: SET               
  105:d=4  hl=2 l=  14 cons: SEQUENCE          
  107:d=5  hl=2 l=   3 prim: OBJECT            :commonName
  112:d=5  hl=2 l=   7 prim: UTF8STRING        :Root CA
  121:d=2  hl=2 l=  89 cons: SEQUENCE          
  123:d=3  hl=2 l=  19 cons: SEQUENCE          
  125:d=4  hl=2 l=   7 prim: OBJECT            :id-ecPublicKey
  134:d=4  hl=2 l=   8 prim: OBJECT            :prime256v1
  144:d=3  hl=2 l=  66 prim: BIT STRING        
  212:d=2  hl=2 l=  83 cons: cont [ 3 ]        
  214:d=3  hl=2 l=  81 cons: SEQUENCE          
  216:d=4  hl=2 l=  29 cons: SEQUENCE          
  218:d=5  hl=2 l=   3 prim: OBJECT            :X509v3 Subject Key Identifier
  223:d=5  hl=2 l=  22 prim: OCTET STRING      [HEX DUMP]:0414C0544C3D7684B36350EE3D5C4F127C04A7FF9524
  247:d=4  hl=2 l=  31 cons: SEQUENCE          
  249:d=5  hl=2 l=   3 prim: OBJECT            :X509v3 Authority Key Identifier
  254:d=5  hl=2 l=  24 prim: OCTET STRING      [HEX DUMP]:30168014C0544C3D7684B36350EE3D5C4F127C04A7FF9524
  280:d=4  hl=2 l=  15 cons: SEQUENCE          
  282:d=5  hl=2 l=   3 prim: OBJECT            :X509v3 Basic Constraints
  287:d=5  hl=2 l=   1 prim: BOOLEAN           :255
  290:d=5  hl=2 l=   5 prim: OCTET STRING      [HEX DUMP]:30030101FF
  297:d=1  hl=2 l=  10 cons: SEQUENCE          
  299:d=2  hl=2 l=   8 prim: OBJECT            :ecdsa-with-SHA256
  309:d=1  hl=2 l=  71 prim: BIT STRING        

In the diff view of the two CA certificates, the bytes that differed preceded the Root CA string in two different places: the Subject and the Issuer, which are the same since this is a self-signed certificate. They were also followed by a 0x07 byte, which aligns with the number of characters in Root CA (i.e. the length of the value). The differing leading byte suggests differing ASN.1 data types for these fields. The CA certificate for which validation is successful uses UTF8String (0x0c), and you can use openssl with the failing CA certificate to see that it uses PrintableString instead (0x13).

openssl asn1parse -in ca.crt.pem
    0:d=0  hl=4 l= 378 cons: SEQUENCE          
    4:d=1  hl=4 l= 289 cons: SEQUENCE          
    8:d=2  hl=2 l=   3 cons: cont [ 0 ]        
   10:d=3  hl=2 l=   1 prim: INTEGER           :02
   13:d=2  hl=2 l=  20 prim: INTEGER           :75AE14BE5173C1010EFDF0F47F88409E3FB274F6
   35:d=2  hl=2 l=  10 cons: SEQUENCE          
   37:d=3  hl=2 l=   8 prim: OBJECT            :ecdsa-with-SHA256
   47:d=2  hl=2 l=  18 cons: SEQUENCE          
   49:d=3  hl=2 l=  16 cons: SET               
   51:d=4  hl=2 l=  14 cons: SEQUENCE          
   53:d=5  hl=2 l=   3 prim: OBJECT            :commonName
   58:d=5  hl=2 l=   7 prim: PRINTABLESTRING   :Root CA
   67:d=2  hl=2 l=  32 cons: SEQUENCE          
   69:d=3  hl=2 l=  13 prim: UTCTIME           :260227194746Z
   84:d=3  hl=2 l=  15 prim: GENERALIZEDTIME   :21260203194746Z
  101:d=2  hl=2 l=  18 cons: SEQUENCE          
  103:d=3  hl=2 l=  16 cons: SET               
  105:d=4  hl=2 l=  14 cons: SEQUENCE          
  107:d=5  hl=2 l=   3 prim: OBJECT            :commonName
  112:d=5  hl=2 l=   7 prim: PRINTABLESTRING   :Root CA
  121:d=2  hl=2 l=  89 cons: SEQUENCE          
  123:d=3  hl=2 l=  19 cons: SEQUENCE          
  125:d=4  hl=2 l=   7 prim: OBJECT            :id-ecPublicKey
  134:d=4  hl=2 l=   8 prim: OBJECT            :prime256v1
  144:d=3  hl=2 l=  66 prim: BIT STRING        
  212:d=2  hl=2 l=  83 cons: cont [ 3 ]        
  214:d=3  hl=2 l=  81 cons: SEQUENCE          
  216:d=4  hl=2 l=  29 cons: SEQUENCE          
  218:d=5  hl=2 l=   3 prim: OBJECT            :X509v3 Subject Key Identifier
  223:d=5  hl=2 l=  22 prim: OCTET STRING      [HEX DUMP]:0414C0544C3D7684B36350EE3D5C4F127C04A7FF9524
  247:d=4  hl=2 l=  31 cons: SEQUENCE          
  249:d=5  hl=2 l=   3 prim: OBJECT            :X509v3 Authority Key Identifier
  254:d=5  hl=2 l=  24 prim: OCTET STRING      [HEX DUMP]:30168014C0544C3D7684B36350EE3D5C4F127C04A7FF9524
  280:d=4  hl=2 l=  15 cons: SEQUENCE          
  282:d=5  hl=2 l=   3 prim: OBJECT            :X509v3 Basic Constraints
  287:d=5  hl=2 l=   1 prim: BOOLEAN           :255
  290:d=5  hl=2 l=   5 prim: OCTET STRING      [HEX DUMP]:30030101FF
  297:d=1  hl=2 l=  10 cons: SEQUENCE          
  299:d=2  hl=2 l=   8 prim: OBJECT            :ecdsa-with-SHA256
  309:d=1  hl=2 l=  71 prim: BIT STRING        

This still doesn’t explain why openssl verifies successfully with either CA certificate, while the Go program does not. To dig further, you can compile and step through the program with gdb, starting with a breakpoint on Verify().

gdb main -ex 'b crypto/x509.(*Certificate).Verify' -ex 'run'

Stepping through the function, you eventually arrive at the point where you are building the candidate certificate chains.

Add a breakpoint for this function using b crypto/x509.(*Certificate).buildChains.

	var candidateChains [][]*Certificate
	if opts.Roots.contains(c) {
		candidateChains = [][]*Certificate{{c}}
	} else {
		candidateChains, err = c.buildChains([]*Certificate{c}, nil, &opts)
		if err != nil {
			return nil, err
		}
	}

As part of evaluating whether the certificate pool provided has a candidate chain, findPotentialParents() is called on the Roots (it is also called on the Intermediates, but there are no intermediate certificates provided in this example).

	for _, root := range opts.Roots.findPotentialParents(c) {
		considerCandidate(rootCertificate, root)
	}

Finally, you arrive at the source of the failure. A potential parent for the leaf certificate should have a Subject that matches the Issuer of the leaf (i.e. the leaf should refer to it as the certificate that was used for signing).

	for _, c := range s.byName[string(cert.RawIssuer)] {
		candidate, constraint, err := s.cert(c)
		if err != nil {
			continue
		}
		kidMatch := bytes.Equal(candidate.SubjectKeyId, cert.AuthorityKeyId)
		switch {
		case kidMatch:
			matchingKeyID = append(matchingKeyID, potentialParent{candidate, constraint})
		case (len(candidate.SubjectKeyId) == 0 && len(cert.AuthorityKeyId) > 0) ||
			(len(candidate.SubjectKeyId) > 0 && len(cert.AuthorityKeyId) == 0):
			oneKeyID = append(oneKeyID, potentialParent{candidate, constraint})
		default:
			mismatchKeyID = append(mismatchKeyID, potentialParent{candidate, constraint})
		}
	}

The keys in the byName map of a CertPool contain the Subject of the CA certificates. When using the CA certificate that caused verification failure, stepping through the loop above you can see that there are zero iterations, or, that there are no CA certificates with a Subject that matches the leaf Issuer. How could that be? The key observation is that the raw Subject and Issuer, the literal bytes, are being used for comparison.

// CertPool is a set of certificates.
type CertPool struct {
	byName map[string][]int // cert.RawSubject => index into lazyCerts

	// lazyCerts contains funcs that return a certificate,
	// lazily parsing/decompressing it as needed.
	lazyCerts []lazyCert

	// haveSum maps from sum224(cert.Raw) to true. It's used only
	// for AddCert duplicate detection, to avoid CertPool.contains
	// calls in the AddCert path (because the contains method can
	// call getCert and otherwise negate savings from lazy getCert
	// funcs).
	haveSum map[sum224]bool

	// systemPool indicates whether this is a special pool derived from the
	// system roots. If it includes additional roots, it requires doing two
	// verifications, one using the roots provided by the caller, and one using
	// the system platform verifier.
	systemPool bool
}

We saw earlier that the two CA certificates differed in the ASN.1 data types used for their Subject. Expectedly, if you check the data type of the Issuer in the leaf certificate, you’ll see that it is a UTF8String, matching the CA certificate that was verified successfully.

openssl asn1parse -in leaf.crt.pem
    0:d=0  hl=4 l= 286 cons: SEQUENCE          
    4:d=1  hl=3 l= 196 cons: SEQUENCE          
    7:d=2  hl=2 l=  20 prim: INTEGER           :2C4DE1BE763153DD60F5CF47DFEB86092A978B83
   29:d=2  hl=2 l=  10 cons: SEQUENCE          
   31:d=3  hl=2 l=   8 prim: OBJECT            :ecdsa-with-SHA256
   41:d=2  hl=2 l=  18 cons: SEQUENCE          
   43:d=3  hl=2 l=  16 cons: SET               
   45:d=4  hl=2 l=  14 cons: SEQUENCE          
   47:d=5  hl=2 l=   3 prim: OBJECT            :commonName
   52:d=5  hl=2 l=   7 prim: UTF8STRING        :Root CA
   61:d=2  hl=2 l=  32 cons: SEQUENCE          
   63:d=3  hl=2 l=  13 prim: UTCTIME           :260227194746Z
   78:d=3  hl=2 l=  15 prim: GENERALIZEDTIME   :21260203194746Z
   95:d=2  hl=2 l=  15 cons: SEQUENCE          
   97:d=3  hl=2 l=  13 cons: SET               
   99:d=4  hl=2 l=  11 cons: SEQUENCE          
  101:d=5  hl=2 l=   3 prim: OBJECT            :commonName
  106:d=5  hl=2 l=   4 prim: UTF8STRING        :leaf
  112:d=2  hl=2 l=  89 cons: SEQUENCE          
  114:d=3  hl=2 l=  19 cons: SEQUENCE          
  116:d=4  hl=2 l=   7 prim: OBJECT            :id-ecPublicKey
  125:d=4  hl=2 l=   8 prim: OBJECT            :prime256v1
  135:d=3  hl=2 l=  66 prim: BIT STRING        
  203:d=1  hl=2 l=  10 cons: SEQUENCE          
  205:d=2  hl=2 l=   8 prim: OBJECT            :ecdsa-with-SHA256
  215:d=1  hl=2 l=  73 prim: BIT STRING        

Whether this is the correct behavior has been an ongoing debate in the Go project, and the matter is complicated by some tools, such as openssl as seen in this post, treating different ASN.1 data types for strings as equivalent when verifying certificates. Typically you’ll be using the same tooling or services for generating CA certificates and the leaf certificates they sign, so the encoding will likely be consistent. However, given that leaf certificates are typically much shorter lived than CA certificates, your tooling may evolve over time, potentially causing discrepancies in newly generated leaves.

Though Go’s handling of this scenario results in fail-closed behavior, it can still cause outages and downtime, making it import to be aware of how you are generating certificates and how they are expected to be verified.