rea-reverse

Decode an X.509 Certificate (PEM) and Check the Chain Order

Published by rea-reverseUpdated 8 min read
On this page

Short answer: openssl x509 -in cert.pem -noout -text shows everything in one certificate. To check a chain, put the leaf in one file and the intermediates in another and run openssl verify -CAfile root.pem -untrusted intermediates.pem leaf.pem. Do not run openssl verify on a fullchain.pem and trust the answer: it checks only the first certificate in the file and ignores the intermediates after it, which I show below. For a quick look without a terminal, paste the PEM into the X.509 certificate decoder. It decodes every certificate in the paste, checks that each one is followed by its issuer, and computes the fingerprints, all in your browser.

Who this is for: developers and ops people who got a certificate bundle from a CA, debug a TLS error in a mobile app or a server, or need the fingerprint of a certificate for pinning. You will learn which fields to read first, how openssl verify really treats its inputs, and what a structural chain check can and cannot tell you.

Read the Certificate. Then the Chain.Silent walkthrough with English captions.

The test material

Everything here was run on 2026-10-20, macOS 27.0.1, OpenSSL 4.0.3 (Homebrew, OPENSSLDIR: "/opt/homebrew/etc/openssl@4"). I used two sets of certificates.

A private chain I created for this article, so I control every field:

  • Demo Root CA: EC P-256, self-signed, 10 years, CA:true, keyCertSign, cRLSign
  • Demo Issuing CA 1: EC P-256, 5 years, CA:true, pathlen:0, signed by the root
  • api.demo.test: RSA 2048, 90 days, SANs DNS:api.demo.test, DNS:*.demo.test, IP:10.0.2.2, serverAuth only, signed by the intermediate

And a public one: the chain letsencrypt.org served that day, fetched with

openssl s_client -connect letsencrypt.org:443 -servername letsencrypt.org -showcerts </dev/null > le.txt

The handshake used TLSv1.3 and TLS_AES_128_GCM_SHA256, ended with Verify return code: 0 (ok), and the server sent four certificates:

 0 s:CN=letsencrypt.org
   i:C=US, O=Let's Encrypt, CN=YE2
 1 s:C=US, O=Let's Encrypt, CN=YE2
   i:C=US, O=ISRG, CN=Root YE
 2 s:C=US, O=ISRG, CN=Root YE
   i:C=US, O=Internet Security Research Group, CN=ISRG Root X2
 3 s:C=US, O=Internet Security Research Group, CN=ISRG Root X2
   i:C=US, O=Internet Security Research Group, CN=ISRG Root X1

The s: / i: pairs already show the chain: each issuer is the next subject. Keep that in mind; it is exactly what the decoder’s chain check tests.

Step 1: read one certificate

openssl x509 -in leaf.pem -noout -text

Trimmed to the fields that answer most questions:

        Serial Number:
            55:6d:88:07:d2:dd:64:fe:58:2e:a1:61:1b:b0:30:3e:1a:26:00:54
        Signature Algorithm: ecdsa-with-SHA256
        Issuer: CN=Demo Issuing CA 1, O=REA Samples
        Validity
            Not Before: Oct 20 09:56:11 2026 GMT
            Not After : Jan 18 09:56:11 2027 GMT
        Subject: CN=api.demo.test
            Public Key Algorithm: rsaEncryption
                Public-Key: (2048 bit)
        X509v3 extensions:
            X509v3 Basic Constraints: critical
                CA:FALSE
            X509v3 Key Usage: critical
                Digital Signature, Key Encipherment
            X509v3 Extended Key Usage:
                TLS Web Server Authentication
            X509v3 Subject Alternative Name:
                DNS:api.demo.test, DNS:*.demo.test, IP Address:10.0.2.2
            X509v3 Authority Key Identifier:
                CA:30:5F:F4:F6:48:A7:74:D7:6D:A8:6D:ED:F9:B7:AC:16:34:81:BF

The signature algorithm is ECDSA although the key is RSA. That is not a mistake: the signature is made by the issuer’s key (an EC intermediate here), the public key is the subject’s own. Mixed chains like this are normal.

What to look at, in the order that usually finds the problem:

  1. Subject Alternative Name. Clients match the host name against the SANs, not against the CN. A wildcard covers one label: *.demo.test matches a.demo.test but not a.b.demo.test (shown with openssl verify below).
  2. Validity. Not After in GMT. openssl x509 -checkend <seconds> answers “will it still be valid then?” with an exit code, which suits monitoring scripts.
  3. Basic Constraints and Key Usage. A leaf should say CA:FALSE; an intermediate needs CA:TRUE and keyCertSign. A pathlen:0 intermediate may sign leaves but not further CAs.
  4. Extended Key Usage. serverAuth for a TLS server. A certificate with only clientAuth will be rejected by a browser even if everything else is right.
  5. Authority Key Identifier. It must equal the issuer’s Subject Key Identifier. More on that in the chain section.

If you only want a few fields, OpenSSL 3 and later accept -ext:

openssl x509 -in le1.pem -noout -subject -issuer -dates -ext subjectAltName,keyUsage,extendedKeyUsage,authorityInfoAccess

For the Let’s Encrypt leaf that printed 10 DNS names (from cp.letsencrypt.org to www.letsencrypt.org), notAfter=Dec 3 14:34:31 2026 GMT (a 90-day certificate issued on Sep 4), Key Usage: critical, Digital Signature, and an Authority Information Access with only a CA Issuers URL, http://ye2.i.lencr.org/. There is no OCSP URL in it, which is worth knowing if a client or a monitoring check insists on OCSP.

Fingerprints

openssl x509 -in leaf.pem -noout -fingerprint
openssl x509 -in leaf.pem -noout -fingerprint -sha256
SHA1 Fingerprint=9A:F2:CB:9C:75:43:C7:6E:EC:71:32:C2:EC:81:13:A0:21:51:5F:51
sha256 Fingerprint=63:FC:B4:03:BB:74:42:E2:53:49:E2:F8:48:6C:97:CD:E0:83:2E:3D:88:9D:E5:2A:AA:97:00:83:FC:E5:86:66

OpenSSL 4.0.3 still defaults to SHA-1 for -fingerprint; add -sha256 when a form or a pinning config wants SHA-256. These are digests of the whole DER certificate. A certificate pin in Android’s network security config is a different value: the Base64 SHA-256 of the public key (SPKI), which survives a renewal with the same key. The decoder shows both.

Step 2: the same certificates in the decoder

I pasted leaf.pem + inter.pem, and then the whole le.txt (with all the s_client noise around the PEM blocks), into the X.509 certificate decoder. To quote real numbers, I also ran the page’s parsing code on the same files under Node 22: the Let’s Encrypt paste of 7,723 bytes decoded to four certificates in 5 ms.

The values agreed with OpenSSL: same SHA-1 and SHA-256, same serials, same SANs, key usage, EKU and AKI/SKI. Three differences in presentation are worth knowing before you compare outputs side by side.

Names are printed in reverse order. OpenSSL showed Issuer: CN=Demo Issuing CA 1, O=REA Samples; the decoder shows O=REA Samples,CN=Demo Issuing CA 1. For Let’s Encrypt it is C=US, O=Let's Encrypt, CN=YE2 versus CN=YE2,O=Let's Encrypt,C=US. The decoder follows RFC 4514, which writes the last RDN of the encoded name first; OpenSSL prints them in encoded order. Same name, different string, so do not diff them as text.

SCT log IDs are Base64. OpenSSL prints the Certificate Transparency log ID as hex (D8:09:55:3B:…); the decoder prints Base64 (2AlVO5RP…), which is how CT log lists publish them. The leaf has two SCTs, both timestamped 2026-09-04.

Validity is shown as days. The page counts whole days left, which for the Let’s Encrypt leaf on 2026-10-20 is 44. My 2024 test certificate (made with openssl req -x509 … -not_before 20240101000000Z -not_after 20250101000000Z) showed as expired; openssl x509 -checkend 0 printed Certificate will expire and returned 1 for it.

Step 3: check the chain with openssl verify

This is where I got the wrong answer first.

openssl verify -CAfile root.pem leaf.pem
CN=api.demo.test
error 20 at 0 depth lookup: unable to get local issuer certificate
error leaf.pem: verification failed

Expected: the intermediate is missing. So I concatenated leaf and intermediate, the way servers want them:

cat leaf.pem inter.pem > fullchain.pem
openssl verify -CAfile root.pem fullchain.pem
error 20 at 0 depth lookup: unable to get local issuer certificate
error fullchain.pem: verification failed

Same error. openssl verify reads only the first certificate of each file you pass as the target. The intermediate in fullchain.pem is never used. Intermediates go in -untrusted:

openssl verify -CAfile root.pem -untrusted inter.pem leaf.pem
leaf.pem: OK

(-untrusted fullchain.pem fullchain.pem also works, since the leaf in the untrusted pool does no harm.) Host names are checked only if you ask:

openssl verify -CAfile root.pem -untrusted inter.pem -verify_hostname api.demo.test leaf.pem   # OK
openssl verify -CAfile root.pem -untrusted inter.pem -verify_hostname a.b.demo.test leaf.pem
error 62 at 0 depth lookup: hostname mismatch

For the public chain I split le.txt into le1.pem (the leaf) and le-inter.pem (the other three). Without -CAfile, OpenSSL uses its own trust store:

openssl verify -show_chain -untrusted le-inter.pem le1.pem
le1.pem: OK
Chain:
depth=0: CN=letsencrypt.org (untrusted)
depth=1: C=US, O=Let's Encrypt, CN=YE2 (untrusted)
depth=2: C=US, O=ISRG, CN=Root YE (untrusted)
depth=3: C=US, O=Internet Security Research Group, CN=ISRG Root X2

Look at depth 3: no “(untrusted)”. OpenSSL stopped at the self-signed ISRG Root X2 in its trust store and did not use the fourth certificate the server sent (X2 cross-signed by ISRG Root X1, valid from 2026-05-13). That cross-sign is there for clients whose trust store has X1 but not X2. Which path a client builds depends on its trust store, so “verifies on my machine” says less than it seems.

What a structural chain check catches

The decoder does not verify signatures and never fetches the AIA or CRL URLs (that would mean contacting the CA from your browser). It checks structure: for each pair, is the issuer name of certificate n byte-for-byte the subject of certificate n+1, and does the authority key identifier match the next subject key identifier? I tried four bundles:

Bundle Decoder’s chain check openssl verify
leaf, intermediate in order: name matches, key identifier matches OK (with -untrusted)
intermediate, leaf not in order: name and key identifier do not match n/a (order is a server-config question)
leaf, fake intermediate with the same name but a new key not in order: name matches, key identifier does not error 20
Let’s Encrypt, 4 certificates in order (3 links), ends without a self-issued root OK

The third row is the useful one. I generated a second CN=Demo Issuing CA 1, O=REA Samples with a fresh key and signed it with the same root. By name it looks like the right issuer; only the key identifiers (and the signature) give it away. A re-keyed intermediate that kept its name looks exactly like this next to a stale leaf. Since signatures are not checked, an “in order” result means “plausible”, not “trusted”; run openssl verify for the trust decision.

A wrong-order bundle is easy to produce when you build fullchain.pem by hand with cat. Servers should send the leaf first, then each intermediate (RFC 8446 still requires the leaf first in TLS 1.3, and asks clients to tolerate the rest being out of order). Do not count on every client being that tolerant.

PKCS#7 bundles have no order

I also wrote the chain into two PKCS#7 files. openssl crl2pkcs7 -nocrl -certfile fullchain.pem -outform DER kept the leaf-first order. A signature made with openssl cms -sign -signer leaf.pem -certfile inter.pem -stream -outform DER stored the intermediate first; openssl pkcs7 -print_certs and the decoder both listed it that way, and the decoder reported “not in order”. That is fine: the certificate set in PKCS#7 is a SET, and nobody should rely on its order. Reorder before you install such a bundle on a server.

That -stream file is BER with indefinite lengths (it starts with 30 80). The tool page said such files were rejected; they decode now, and I updated the page’s limitations.

A note on pasting certificates into websites

Certificates are public, but the habit of pasting PEM into any website is risky: the next paste might contain the private key from the same file. I tested exactly that with a file holding leaf.key, leaf.pem and inter.pem. The decoder’s parser skipped the key block without decoding it (“Blocks that are not certificates (private keys, CSRs) were skipped without being read.”) and decoded the two certificates. The page does this in a Web Worker whose Content Security Policy has connect-src 'none', so it cannot send the text anywhere. That is the minimum to expect from any online decoder; better still, strip keys before pasting.

Where REA fits

If the certificate you are debugging comes from an app’s network traffic, the next step is usually the traffic, not the certificate. The website API tracing guide shows how REA, the open-source project by morluto, captures and reads HAR files and front-end bundles, and the Android APK guide covers finding certificate pinning code in an app. Only test systems and apps you own or are authorized to assess; see REA’s DISCLAIMER.md.

Next steps