Skip to main content
Cryptography · lesson 6 of 8
Cryptography intermediate Lesson 6 of 8

X.509 Certificates: Build a Local CA and Watch Verification Fail

Build a throwaway CA with openssl, issue a server certificate, then watch verify fail with error 20, pass with -CAfile, and reject a hostname mismatch.

By SkillByExample · Published

cryptography x509 certificates pki openssl tls

Lesson 5 left a gap: to verify a signature you need the right public key, but how do you know which key is right? A certificate answers that. It’s a public key plus a name, signed by an authority you already trust. Here you build a throwaway certificate authority (CA), issue a server certificate from it, and watch each verification check fail and then pass. The CA is demo-only. It lives in one directory, has a 30-day lifetime, and is never added to a system trust store.

Run the scripts in order, in one directory, inside docker run --rm -it -v "$PWD":/work -w /work python:3.12-slim bash. That image already includes the openssl command. They were run with OpenSSL 3.5.7 on Debian 13.7, and Python 3.12.15 with cryptography 50.0.2 for the Python practice task. Your dates and fingerprints will differ.

A root CA: a self-signed certificate

A root CA is just a key pair and a certificate that signs itself.

# make_ca.sh  -- a demo-only CA for this lab; never add it to a system trust store
openssl genpkey -algorithm EC -pkeyopt ec_paramgen_curve:P-256 -out ca.key
openssl req -x509 -new -key ca.key -days 30 \
  -subj "/CN=Demo Lab Root CA" -out ca.crt
openssl x509 -in ca.crt -noout -subject -issuer -enddate -ext basicConstraints
$ sh make_ca.sh
subject=CN=Demo Lab Root CA
issuer=CN=Demo Lab Root CA
notAfter=Nov 12 19:59:12 2026 GMT
X509v3 Basic Constraints: critical
    CA:TRUE

Subject and issuer are the same name, which is what “self-signed” means. Nothing signed this certificate except its own key, so it carries no proof of anything. A root is trusted only because someone chose to put it in a trust store. CA:TRUE in Basic Constraints marks it as allowed to sign other certificates, and it’s marked critical, so software that doesn’t understand the extension must reject the certificate. ca.key is the most sensitive file in any PKI. Real CAs keep it offline or in a hardware security module.

Issuing a server certificate

The server makes its own key pair and a certificate signing request (CSR). The CA signs it, adding the names the certificate is valid for.

# issue_leaf.sh
openssl genpkey -algorithm EC -pkeyopt ec_paramgen_curve:P-256 -out server.key
openssl req -new -key server.key -subj "/CN=app.lab.test" -out server.csr

printf 'subjectAltName=DNS:app.lab.test,DNS:localhost\n' > leaf.ext
printf 'extendedKeyUsage=serverAuth\nbasicConstraints=CA:FALSE\n' >> leaf.ext

openssl x509 -req -in server.csr -CA ca.crt -CAkey ca.key -days 7 \
  -extfile leaf.ext -out server.crt
openssl x509 -in server.crt -noout -subject -issuer -enddate \
  -ext subjectAltName,extendedKeyUsage
$ sh issue_leaf.sh
Certificate request self-signature ok
subject=CN=app.lab.test
subject=CN=app.lab.test
issuer=CN=Demo Lab Root CA
notAfter=Oct 20 19:59:13 2026 GMT
X509v3 Subject Alternative Name: 
    DNS:app.lab.test, DNS:localhost
X509v3 Extended Key Usage: 
    TLS Web Server Authentication

The first two lines come from openssl x509 -req: it checks that the CSR is signed by the key it contains, then reports the subject it’s signing. The server’s private key never left the server: the CSR holds only the public key, and the CA never sees server.key. The fields that matter later are:

  • Subject Alternative Name lists the hostnames the certificate is valid for. Browsers and TLS libraries check only this, not the CN.
  • Extended Key Usage serverAuth limits the certificate to TLS servers.
  • notAfter is 7 days away. Short lifetimes limit the damage from a leaked key. Public CAs are moving towards 47-day maximums.

For the full field dump, run openssl x509 -in server.crt -noout -text.

Verification without, then with, the CA

A verifier has to chain the certificate back to a CA it trusts. First, try without telling it about the lab CA.

# verify.sh
openssl verify server.crt
echo "exit: $?"
openssl verify -CAfile ca.crt server.crt
echo "exit: $?"
$ sh verify.sh
CN=app.lab.test
error 20 at 0 depth lookup: unable to get local issuer certificate
error server.crt: verification failed
exit: 2
server.crt: OK
exit: 0

Error 20, unable to get local issuer certificate, means OpenSSL looked through its default trust store for the issuer Demo Lab Root CA and didn’t find it. “At 0 depth” means the problem is at the leaf, the first certificate in the chain. That’s the correct result: nobody should trust a certificate from a CA they’ve never heard of. With -CAfile ca.crt, the lab CA is trusted for this one command, the signature checks out, and the result is OK.

You’ll meet error 20 in real deployments too, most often when a server sends its own certificate but not the intermediate CA between it and the root. The fix is always to supply the missing CA or chain. It’s never to turn verification off.

A valid certificate for the wrong name

A correct signature only proves that the CA issued the certificate to someone. The verifier must also check that it was issued for the host it’s talking to.

# hostname.sh
openssl verify -CAfile ca.crt -verify_hostname app.lab.test server.crt
openssl verify -CAfile ca.crt -verify_hostname localhost server.crt
openssl verify -CAfile ca.crt -verify_hostname bank.example server.crt
echo "exit: $?"
$ sh hostname.sh
server.crt: OK
server.crt: OK
CN=app.lab.test
error 62 at 0 depth lookup: hostname mismatch
error server.crt: verification failed
exit: 2

Both names in the SAN pass, and bank.example fails with error 62, hostname mismatch. Without that check, anyone holding any certificate from a CA you trust could impersonate every site. openssl verify only checks the hostname when you ask with -verify_hostname. Most TLS clients, including browsers, curl and Python’s ssl.create_default_context(), check it by default. Code that disables it, such as check_hostname = False, has the same hole. Lesson 7 runs this same check inside a real TLS handshake.

Practice

  1. Check expiry without waiting. openssl verify -attime <unix-time> verifies as if at another time. Verify server.crt 3 days and 10 days from now.
Solution
# expiry.sh
now=$(date +%s)
openssl verify -CAfile ca.crt -attime $((now + 3*86400)) server.crt
openssl verify -CAfile ca.crt -attime $((now + 10*86400)) server.crt
echo "exit: $?"
$ sh expiry.sh
server.crt: OK
CN=app.lab.test
error 10 at 0 depth lookup: certificate has expired
error server.crt: verification failed
exit: 2

At 10 days, the 7-day certificate has expired: error 10. An expired certificate is rejected even though its signature is still mathematically valid. That’s what forces regular renewal, and why it pays to automate renewal (ACME, cert-manager) and alert on notAfter.

  1. Try a CA that copies the real CA’s name. Make a second CA called Demo Lab Root CA, use it to sign the same CSR, and verify the result against the real ca.crt.
Solution
# rogue_ca.sh  -- a second demo CA that copies the first one's name
openssl genpkey -algorithm EC -pkeyopt ec_paramgen_curve:P-256 -out rogue.key
openssl req -x509 -new -key rogue.key -days 30 \
  -subj "/CN=Demo Lab Root CA" -out rogue.crt
openssl x509 -req -in server.csr -CA rogue.crt -CAkey rogue.key -days 7 \
  -extfile leaf.ext -out forged.crt 2>/dev/null
openssl x509 -in forged.crt -noout -issuer
openssl verify -CAfile ca.crt forged.crt
echo "exit: $?"
$ sh rogue_ca.sh
issuer=CN=Demo Lab Root CA
CN=app.lab.test
error 20 at 0 depth lookup: unable to get local issuer certificate
error forged.crt: verification failed
exit: 2

The issuer name is identical, and verification still fails. A name is just text that anyone can type. What links a certificate to its issuer is the signature, plus an Authority Key Identifier extension that names the issuer’s key. Here the identifier points at the rogue key, so OpenSSL doesn’t even accept ca.crt as a candidate issuer, and it reports error 20.

  1. Use the server certificate as a CA. Sign a certificate for bank.lab.test with server.crt and server.key, then verify it with the lab CA trusted and server.crt supplied as an intermediate (-untrusted).
Solution
# leaf_as_ca.sh  -- can a server certificate issue certificates of its own?
openssl genpkey -algorithm EC -pkeyopt ec_paramgen_curve:P-256 -out other.key
openssl req -new -key other.key -subj "/CN=bank.lab.test" -out other.csr
printf 'subjectAltName=DNS:bank.lab.test\n' > other.ext
openssl x509 -req -in other.csr -CA server.crt -CAkey server.key -days 7 \
  -extfile other.ext -out other.crt 2>/dev/null
openssl verify -CAfile ca.crt -untrusted server.crt other.crt
echo "exit: $?"
$ sh leaf_as_ca.sh
CN=app.lab.test
error 79 at 1 depth lookup: invalid CA certificate
error other.crt: verification failed
exit: 2

openssl x509 -req happily produced the certificate, because signing is just maths. The verifier refuses it with error 79 at depth 1, the would-be intermediate, because server.crt says CA:FALSE. That’s why basicConstraints=CA:FALSE belongs on every leaf certificate. Without it, anyone who stole a web server’s key could mint certificates for other sites. Some old TLS libraries skipped this check, which caused real impersonation bugs.

  1. Read the certificate from Python. Load server.crt with cryptography.x509 and print the subject, issuer, SAN names, expiry, key type and SHA-256 fingerprint. Compare the fingerprint with openssl x509 -fingerprint -sha256.
Solution
# read_cert.py
from cryptography import x509
from cryptography.hazmat.primitives import hashes

with open("server.crt", "rb") as f:
    cert = x509.load_pem_x509_certificate(f.read())

san = cert.extensions.get_extension_for_class(x509.SubjectAlternativeName)
print("subject:    ", cert.subject.rfc4514_string())
print("issuer:     ", cert.issuer.rfc4514_string())
print("SAN:        ", san.value.get_values_for_type(x509.DNSName))
print("not after:  ", cert.not_valid_after_utc)
print("key type:   ", cert.public_key().curve.name)
print("sha256 fp:  ", cert.fingerprint(hashes.SHA256()).hex(":").upper()[:47], "...")
$ python read_cert.py
subject:     CN=app.lab.test
issuer:      CN=Demo Lab Root CA
SAN:         ['app.lab.test', 'localhost']
not after:   2026-10-20 19:59:13+00:00
key type:    secp256r1
sha256 fp:   D6:85:19:CE:22:6F:F8:1D:93:96:90:AD:7C:FD:C8:CB ...
$ openssl x509 -in server.crt -noout -fingerprint -sha256
sha256 Fingerprint=D6:85:19:CE:22:6F:F8:1D:93:96:90:AD:7C:FD:C8:CB:B5:95:71:BC:2A:41:2E:CC:AB:A2:0F:2C:4A:36:64:D6

Both tools report the same fingerprint, because it’s simply SHA-256 over the certificate’s DER bytes. This is the value to compare when pinning a certificate or checking one out of band. secp256r1 is OpenSSL’s other name for P-256. Reading fields is safe, but loading a certificate checks nothing, not even its signature. For verification, use a TLS library or cryptography.x509.verification, never your own field-by-field checks.

Frequently Asked Questions

What is an X.509 certificate?
An X.509 certificate is a signed statement binding a public key to a name. It holds the subject's public key, the names it is valid for in the Subject Alternative Name extension, a validity period and the issuer's name, and the whole thing is signed by the issuer's private key. A client trusts the certificate if it can follow those signatures back to a CA it already trusts.
What does 'unable to get local issuer certificate' mean?
It is OpenSSL verification error 20: the verifier could not find the certificate of whoever signed this one among the CAs it trusts. Usually the issuing CA isn't in the trust store, or the server didn't send its intermediate certificate. Fix it by supplying the right CA with -CAfile, or by configuring the server to send its full chain, never by turning verification off.
Why does certificate verification need a hostname check?
A valid signature only proves a trusted CA issued the certificate to someone. Without checking that the name you connected to appears in the certificate's Subject Alternative Name, any valid certificate from the same CA would be accepted for any site. OpenSSL reports error 62, hostname mismatch, when you pass -verify_hostname and the name isn't listed.
Is it safe to create my own certificate authority for testing?
Yes, if the CA stays inside the test environment. Keep the CA key out of source control, give it a short lifetime, and pass its certificate explicitly to the clients that need it, for example with -CAfile or a client's CA bundle option. Never add a home-made CA to an operating system or browser trust store, because anyone who gets its key could then impersonate any website to that machine.
What is the Subject Alternative Name (SAN) field?
The Subject Alternative Name extension lists every DNS name and IP address a certificate is valid for. Modern browsers and TLS libraries check the hostname only against the SAN and ignore the older Common Name field, so a certificate without the right SAN entry fails hostname verification even if its CN looks correct.