Certificate Management: X.509 and Certificate Authorities

X.509 certificates are the backbone of secure communication on the internet. Every HTTPS connection, every signed email, every authenticated API call relies on these digital documents to establish...

Key Insights

  • X.509 certificates form the foundation of internet security, but their complexity leads to frequent misconfigurations—understanding certificate structure and chain validation is essential for building secure systems.
  • Automated certificate management through ACME isn’t optional anymore; manual certificate processes are a liability that leads to outages and security gaps.
  • Certificate validation goes beyond expiry checks—proper implementation requires chain verification, revocation checking, and hostname validation, all of which are commonly overlooked.

Introduction to X.509 Certificates

X.509 certificates are the backbone of secure communication on the internet. Every HTTPS connection, every signed email, every authenticated API call relies on these digital documents to establish trust between parties who’ve never met.

At its core, an X.509 certificate binds a public key to an identity. When your browser connects to your bank’s website, the certificate proves that the public key you’re using to encrypt data actually belongs to your bank—not an attacker sitting between you and the server.

The trust model is hierarchical. Certificate Authorities (CAs) act as trusted third parties. Your operating system and browser ship with a set of pre-trusted root certificates. When a server presents its certificate, your client traces the chain back to one of these roots. If the chain is valid and unbroken, trust is established.

This model has flaws—compromised CAs have caused real damage—but it scales to the entire internet in a way that alternative trust models haven’t matched.

Anatomy of an X.509 Certificate

Understanding certificate structure helps you debug TLS issues and implement proper validation. Here’s what’s inside:

Version: Almost always v3 (value 2 in the encoding). Version 3 added extensions, which are critical for modern use.

Serial Number: Unique identifier within a CA. Used for revocation tracking.

Signature Algorithm: How the CA signed this certificate (e.g., SHA256withRSA).

Issuer: The CA that signed the certificate. Distinguished Name format.

Validity Period: Not Before and Not After timestamps. Certificates outside this window must be rejected.

Subject: The identity the certificate represents. For websites, this traditionally contained the domain name.

Public Key: The actual cryptographic key being certified, along with its algorithm.

Extensions: Where the real action happens. Key extensions include:

  • Subject Alternative Name (SAN): Modern way to specify domains, IPs, or emails
  • Key Usage: What the key can do (signing, encryption, etc.)
  • Basic Constraints: Whether this certificate can sign other certificates
  • Authority Key Identifier / Subject Key Identifier: Help build certificate chains

Let’s examine a real certificate:

# Download and parse a certificate
echo | openssl s_client -connect example.com:443 -servername example.com 2>/dev/null | \
  openssl x509 -noout -text

# Key fields only
openssl s_client -connect example.com:443 -servername example.com 2>/dev/null | \
  openssl x509 -noout -subject -issuer -dates -serial

Here’s how to parse certificates programmatically in Python:

from cryptography import x509
from cryptography.hazmat.backends import default_backend
import ssl
import socket

def get_certificate_info(hostname: str, port: int = 443) -> dict:
    """Fetch and parse a server's certificate."""
    context = ssl.create_default_context()
    
    with socket.create_connection((hostname, port)) as sock:
        with context.wrap_socket(sock, server_hostname=hostname) as ssock:
            der_cert = ssock.getpeercert(binary_form=True)
    
    cert = x509.load_der_x509_certificate(der_cert, default_backend())
    
    # Extract Subject Alternative Names
    try:
        san_ext = cert.extensions.get_extension_for_class(x509.SubjectAlternativeName)
        sans = [name.value for name in san_ext.value]
    except x509.ExtensionNotFound:
        sans = []
    
    return {
        "subject": cert.subject.rfc4514_string(),
        "issuer": cert.issuer.rfc4514_string(),
        "serial_number": hex(cert.serial_number),
        "not_valid_before": cert.not_valid_before_utc,
        "not_valid_after": cert.not_valid_after_utc,
        "san": sans,
        "signature_algorithm": cert.signature_algorithm_oid._name,
    }

info = get_certificate_info("github.com")
print(f"Subject: {info['subject']}")
print(f"Valid until: {info['not_valid_after']}")
print(f"SANs: {info['san']}")

Certificate Authorities and Chain of Trust

Trust flows downward from root CAs. Root certificates are self-signed—they sign themselves. These roots sign intermediate CA certificates, which in turn sign end-entity certificates (the ones servers actually use).

Why intermediates? Root keys are extraordinarily valuable. They’re kept offline in hardware security modules, accessed only for signing intermediate certificates. Intermediates handle day-to-day issuance. If an intermediate is compromised, it can be revoked without replacing the root in every trust store on earth.

When validating a certificate chain:

  1. Start with the end-entity certificate
  2. Find the certificate that signed it (issuer matches subject of next cert)
  3. Verify the signature using the issuer’s public key
  4. Repeat until you reach a trusted root
  5. Verify each certificate’s validity period and constraints
# View the full certificate chain
openssl s_client -connect github.com:443 -servername github.com -showcerts 2>/dev/null | \
  grep -E "^(Certificate chain| [0-9]+ s:|   i:)"

# Verify a certificate against a chain
openssl verify -CAfile chain.pem server.crt

# Verify with explicit intermediate
openssl verify -CAfile root.pem -untrusted intermediate.pem server.crt

Programmatic chain verification in Python:

from cryptography import x509
from cryptography.hazmat.backends import default_backend
from cryptography.hazmat.primitives import hashes
from cryptography.hazmat.primitives.asymmetric import padding
from datetime import datetime, timezone

def verify_chain(cert_chain: list[x509.Certificate], trusted_roots: list[x509.Certificate]) -> bool:
    """Verify a certificate chain against trusted roots."""
    if not cert_chain:
        return False
    
    now = datetime.now(timezone.utc)
    
    for i, cert in enumerate(cert_chain):
        # Check validity period
        if now < cert.not_valid_before_utc or now > cert.not_valid_after_utc:
            print(f"Certificate {i} is outside validity period")
            return False
        
        # Find issuer (next in chain or trusted root)
        if i + 1 < len(cert_chain):
            issuer_cert = cert_chain[i + 1]
        else:
            # Must be signed by a trusted root
            issuer_cert = next(
                (root for root in trusted_roots 
                 if root.subject == cert.issuer),
                None
            )
            if issuer_cert is None:
                print(f"No trusted root found for {cert.subject}")
                return False
        
        # Verify signature
        try:
            issuer_cert.public_key().verify(
                cert.signature,
                cert.tbs_certificate_bytes,
                padding.PKCS1v15(),
                cert.signature_hash_algorithm,
            )
        except Exception as e:
            print(f"Signature verification failed: {e}")
            return False
    
    return True

Generating and Managing Certificates

For development and internal services, you’ll need to generate your own certificates. Here’s the practical workflow:

# Generate a private key (use 2048 minimum, 4096 for long-lived keys)
openssl genrsa -out server.key 4096

# Create a Certificate Signing Request
openssl req -new -key server.key -out server.csr \
  -subj "/CN=myservice.internal/O=MyCompany"

# For SANs, you need a config file
cat > san.cnf << EOF
[req]
distinguished_name = req_distinguished_name
req_extensions = v3_req

[req_distinguished_name]
CN = myservice.internal

[v3_req]
subjectAltName = @alt_names

[alt_names]
DNS.1 = myservice.internal
DNS.2 = *.myservice.internal
IP.1 = 10.0.0.50
EOF

openssl req -new -key server.key -out server.csr -config san.cnf

# Self-signed certificate (for testing only)
openssl req -x509 -nodes -days 365 -key server.key -out server.crt \
  -subj "/CN=localhost" -addext "subjectAltName=DNS:localhost,IP:127.0.0.1"

For a proper internal CA, use cfssl:

# Install cfssl
go install github.com/cloudflare/cfssl/cmd/...@latest

# Create CA configuration
cat > ca-csr.json << EOF
{
  "CN": "Internal CA",
  "key": { "algo": "rsa", "size": 4096 },
  "names": [{ "O": "MyCompany", "OU": "Infrastructure" }],
  "ca": { "expiry": "87600h" }
}
EOF

# Generate CA
cfssl gencert -initca ca-csr.json | cfssljson -bare ca

# Sign a server certificate
cat > server-csr.json << EOF
{
  "CN": "myservice.internal",
  "hosts": ["myservice.internal", "10.0.0.50"],
  "key": { "algo": "rsa", "size": 2048 }
}
EOF

cfssl gencert -ca=ca.pem -ca-key=ca-key.pem server-csr.json | cfssljson -bare server

Certificate Validation and Revocation

Proper validation requires more than checking expiry. You must verify the chain, check revocation status, and validate the hostname matches.

Certificate Revocation Lists (CRLs) are the original revocation mechanism—a signed list of revoked serial numbers. They’re simple but don’t scale well.

OCSP (Online Certificate Status Protocol) queries revocation status in real-time. OCSP stapling lets servers include a recent OCSP response in the TLS handshake, improving performance and privacy.

from cryptography.x509 import ocsp
from cryptography.hazmat.primitives import serialization
from cryptography.hazmat.primitives.hashes import SHA256
import requests

def check_ocsp(cert: x509.Certificate, issuer: x509.Certificate) -> str:
    """Check certificate revocation status via OCSP."""
    # Get OCSP responder URL from certificate
    try:
        aia = cert.extensions.get_extension_for_class(x509.AuthorityInformationAccess)
        ocsp_urls = [
            desc.access_location.value 
            for desc in aia.value 
            if desc.access_method == x509.oid.AuthorityInformationAccessOID.OCSP
        ]
    except x509.ExtensionNotFound:
        return "NO_OCSP_URL"
    
    if not ocsp_urls:
        return "NO_OCSP_URL"
    
    # Build OCSP request
    builder = ocsp.OCSPRequestBuilder()
    builder = builder.add_certificate(cert, issuer, SHA256())
    request = builder.build()
    
    # Send request
    response = requests.post(
        ocsp_urls[0],
        data=request.public_bytes(serialization.Encoding.DER),
        headers={"Content-Type": "application/ocsp-request"},
    )
    
    ocsp_response = ocsp.load_der_ocsp_response(response.content)
    return ocsp_response.certificate_status.name  # GOOD, REVOKED, or UNKNOWN

Automating Certificate Lifecycle with ACME

ACME (Automatic Certificate Management Environment) changed everything. Let’s Encrypt issues free certificates, and ACME automates the entire process.

The basic flow:

  1. Client proves control of domain (HTTP challenge, DNS challenge, or TLS-ALPN)
  2. CA issues certificate
  3. Client installs certificate
  4. Repeat before expiry (Let’s Encrypt certs last 90 days)
# Certbot is the standard ACME client
# Standalone mode (temporarily binds port 80)
certbot certonly --standalone -d example.com

# Webroot mode (uses existing web server)
certbot certonly --webroot -w /var/www/html -d example.com

# DNS challenge (for wildcards and non-web services)
certbot certonly --manual --preferred-challenges dns -d "*.example.com"

# Auto-renewal (add to cron or systemd timer)
certbot renew --quiet --post-hook "systemctl reload nginx"

For programmatic ACME in production, use purpose-built libraries:

# Using the acme library directly
from acme import client, messages
from josepy import JWKRSA
from cryptography.hazmat.primitives.asymmetric import rsa

# Generate account key
account_key = rsa.generate_private_key(public_exponent=65537, key_size=2048)
jwk = JWKRSA(key=account_key)

# Create ACME client
directory_url = "https://acme-v02.api.letsencrypt.org/directory"
network = client.ClientNetwork(jwk)
directory = messages.Directory.from_json(network.get(directory_url).json())
acme_client = client.ClientV2(directory, network)

# Register account
registration = acme_client.new_account(
    messages.NewRegistration.from_data(
        email="admin@example.com",
        terms_of_service_agreed=True
    )
)

Common Pitfalls and Security Considerations

Incomplete certificate chains: Servers must send the full chain (except the root). Missing intermediates cause validation failures on clients that don’t fetch missing certificates.

# Test your chain configuration
openssl s_client -connect yoursite.com:443 -servername yoursite.com 2>/dev/null | \
  openssl verify -verbose

Certificate pinning: Pinning ties your application to specific certificates or public keys. It’s powerful against CA compromise but dangerous—pin to your CA’s public key, not individual certificates, and always have a backup pin.

Key protection: Private keys must be protected. Use file permissions (600), consider hardware security modules for high-value keys, and never commit keys to version control.

Expiration monitoring: Set up alerts well before certificates expire. A 30-day warning gives you time to act. Tools like Prometheus with blackbox exporter can monitor certificate expiry across your infrastructure.

Clock skew: Certificate validation depends on accurate time. NTP isn’t optional. A server with a drifting clock will reject valid certificates or accept expired ones.

The certificate ecosystem is complex, but these fundamentals—understanding structure, validating chains properly, automating lifecycle management, and monitoring expiration—will keep your systems secure and your on-call engineers sleeping through the night.

Liked this? There's more.

Every week: one practical technique, explained simply, with code you can use immediately.