Managing SSL Certificates with Python
SSL/TLS certificates are the backbone of secure communication on the modern web. They authenticate the identity of a server, enable encrypted channels between clients and hosts, and underpin the trust model that browsers, APIs, mail servers, and countless other services rely on. As soon as you run more than a handful of services, however, certificate management stops being a one-off task and becomes an ongoing operational concern: certificates expire, chains need to be validated, hostnames must be verified, and renewals have to be automated before an outage forces the issue.
Python offers an unusually good toolbox for this work. The standard library ships the ssl module for establishing secure connections and inspecting the certificates a server presents, while the third-party cryptography library gives you full programmatic control over keys, certificate parsing, signing requests, and chain validation. This article walks through the practical side of certificate management: reading and understanding certificates, generating your own, creating signing requests, validating chains and expiration, verifying hostnames, and automating the monitoring that keeps expired certificates from ever reaching production.
Anatomy of an X.509 Certificate
Before writing any code, it helps to have a clear mental model of what a certificate actually contains. Virtually every TLS certificate in use today follows the X.509 standard. An X.509 certificate is a signed data structure that binds a public key to an identity. The most important fields are the following.
- Subject: the distinguished name (DN) of the entity the certificate identifies, typically including a common name (CN) and often an organization and country.
- Issuer: the distinguished name of the certificate authority (CA) that signed and issued the certificate.
- Validity period: the not before and not after timestamps that bound the certificate's lifetime.
- Public key: the public half of the key pair, used by clients to establish encrypted sessions and verify signatures.
- Serial number: a unique identifier assigned by the issuing CA.
- Subject Alternative Names (SAN): the list of DNS names, IP addresses, or other identifiers the certificate is valid for. Modern clients validate hostnames against the SAN extension rather than the common name.
- Signature: the CA's cryptographic signature over the certificate body, which is what allows the whole chain of trust to be verified.
A certificate rarely stands alone. It is usually part of a chain: the server (leaf) certificate is signed by an intermediate CA, which is in turn signed by a root CA whose certificate is pre-installed in the operating system or browser trust store. Validating a certificate means walking this chain up to a trusted root and confirming every signature along the way.
Inspecting a Remote Certificate with the Standard Library
The quickest way to look at a live certificate is to connect to a server and ask for what it presents. The ssl module, combined with a plain socket, does this without any external dependencies. The following function opens a TLS connection to a host and returns the parsed certificate dictionary.
import ssl
import socket
def fetch_certificate(hostname: str, port: int = 443, timeout: float = 10.0) -> dict:
"""Connect to a host over TLS and return the parsed peer certificate."""
context = ssl.create_default_context()
# wrap_socket performs the handshake and validates the chain by default
with socket.create_connection((hostname, port), timeout=timeout) as sock:
with context.wrap_socket(sock, server_hostname=hostname) as tls_sock:
return tls_sock.getpeercert()
cert = fetch_certificate("www.python.org")
print(cert["subject"])
print(cert["issuer"])
print(cert["notAfter"])
The create_default_context factory returns a context configured with secure defaults: it loads the system trust store, enables hostname checking, and requires certificate validation. Because validation is on by default, wrap_socket raises an ssl.SSLCertVerificationError if the certificate is expired, self-signed, or does not match the hostname. The returned dictionary contains the subject, issuer, validity dates, and the SAN entries under the subjectAltName key.
There is one important subtlety: getpeercert only returns a populated dictionary when the certificate has been verified. If you disable verification, it returns an empty structure. To inspect the raw certificate regardless of trust, request the binary form and decode it separately.
def fetch_certificate_der(hostname: str, port: int = 443) -> bytes:
"""Return the leaf certificate in DER form, without requiring trust."""
context = ssl.create_default_context()
# Disable validation so we can inspect untrusted or self-signed certs
context.check_hostname = False
context.verify_mode = ssl.CERT_NONE
with socket.create_connection((hostname, port)) as sock:
with context.wrap_socket(sock, server_hostname=hostname) as tls_sock:
return tls_sock.getpeercert(binary_form=True)
Disabling verification is appropriate only for inspection or diagnostics. In any code path that transmits real data, leave the default secure context untouched.
Parsing Certificates with the cryptography Library
The standard library is fine for quick checks, but its parsed output is limited and awkward to work with. For serious certificate handling, the cryptography library is the tool of choice. Install it with pip.
pip install cryptography
The library can load certificates from either DER (binary) or PEM (base64-wrapped) encoding and exposes every field as a structured object. Combining it with the DER fetch above gives a rich, queryable certificate.
from cryptography import x509
from cryptography.hazmat.primitives import hashes
from cryptography.x509.oid import NameOID, ExtensionOID
def describe_certificate(der_bytes: bytes) -> None:
"""Load a DER certificate and print its most relevant fields."""
cert = x509.load_der_x509_certificate(der_bytes)
subject = cert.subject.get_attributes_for_oid(NameOID.COMMON_NAME)
issuer = cert.issuer.get_attributes_for_oid(NameOID.COMMON_NAME)
print(f"Subject CN: {subject[0].value if subject else 'n/a'}")
print(f"Issuer CN: {issuer[0].value if issuer else 'n/a'}")
print(f"Serial: {cert.serial_number}")
print(f"Not before: {cert.not_valid_before_utc}")
print(f"Not after: {cert.not_valid_after_utc}")
# The SHA-256 fingerprint uniquely identifies this certificate
fingerprint = cert.fingerprint(hashes.SHA256()).hex(":")
print(f"SHA-256: {fingerprint}")
# Extract the Subject Alternative Names, the field clients actually check
try:
san = cert.extensions.get_extension_for_oid(
ExtensionOID.SUBJECT_ALTERNATIVE_NAME
)
names = san.value.get_values_for_type(x509.DNSName)
print(f"SAN: {', '.join(names)}")
except x509.ExtensionNotFound:
print("SAN: none")
Loading a certificate from a PEM file on disk is equally straightforward and is the more common case when working with certificates issued by a CA.
from pathlib import Path
def load_pem_certificate(path: str) -> x509.Certificate:
"""Load an X.509 certificate from a PEM-encoded file."""
pem_data = Path(path).read_bytes()
return x509.load_pem_x509_certificate(pem_data)
cert = load_pem_certificate("/etc/ssl/certs/example.pem")
print(cert.subject.rfc4514_string())
Generating a Private Key and a Self-Signed Certificate
Self-signed certificates are useful for local development, internal services, and testing TLS code paths without involving a public CA. Producing one requires two steps: generating a key pair, then building and signing a certificate with it. The cryptography library handles both.
import datetime
from cryptography import x509
from cryptography.x509.oid import NameOID
from cryptography.hazmat.primitives import hashes, serialization
from cryptography.hazmat.primitives.asymmetric import rsa
def generate_self_signed(common_name: str, days_valid: int = 365):
"""Create an RSA key and a matching self-signed certificate."""
# 1. Generate a 2048-bit RSA private key
key = rsa.generate_private_key(public_exponent=65537, key_size=2048)
# 2. Build the subject and issuer (identical for a self-signed cert)
name = x509.Name([
x509.NameAttribute(NameOID.COUNTRY_NAME, "IT"),
x509.NameAttribute(NameOID.ORGANIZATION_NAME, "Example Srl"),
x509.NameAttribute(NameOID.COMMON_NAME, common_name),
])
now = datetime.datetime.now(datetime.timezone.utc)
# 3. Assemble the certificate and sign it with the private key
cert = (
x509.CertificateBuilder()
.subject_name(name)
.issuer_name(name)
.public_key(key.public_key())
.serial_number(x509.random_serial_number())
.not_valid_before(now)
.not_valid_after(now + datetime.timedelta(days=days_valid))
.add_extension(
x509.SubjectAlternativeName([x509.DNSName(common_name)]),
critical=False,
)
.add_extension(
x509.BasicConstraints(ca=False, path_length=None),
critical=True,
)
.sign(key, hashes.SHA256())
)
return key, cert
def write_key_and_cert(key, cert, key_path: str, cert_path: str) -> None:
"""Persist the key and certificate to PEM files."""
with open(key_path, "wb") as f:
f.write(key.private_bytes(
encoding=serialization.Encoding.PEM,
format=serialization.PrivateFormat.PKCS8,
encryption_algorithm=serialization.NoEncryption(),
))
with open(cert_path, "wb") as f:
f.write(cert.public_bytes(serialization.Encoding.PEM))
key, cert = generate_self_signed("localhost")
write_key_and_cert(key, cert, "server.key", "server.crt")
Two extensions matter here. The SubjectAlternativeName extension is mandatory in practice: without it, modern clients reject the certificate even if the common name matches. The BasicConstraints extension with ca=False marks the certificate as a leaf, not a CA, which is the correct choice for a server certificate. When you write the private key, protect it appropriately: the example above uses NoEncryption for simplicity, but production keys should either be encrypted at rest or stored with strict filesystem permissions.
Creating a Certificate Signing Request
When you want a certificate issued by a real CA, you do not generate the certificate yourself. Instead you produce a Certificate Signing Request (CSR): a document containing your public key and desired identity, signed by your private key, which the CA then uses to issue a certificate. The CSR proves you control the private key without ever exposing it.
from cryptography import x509
from cryptography.x509.oid import NameOID
from cryptography.hazmat.primitives import hashes, serialization
from cryptography.hazmat.primitives.asymmetric import rsa
def generate_csr(common_name: str, alt_names: list[str]):
"""Generate a private key and a CSR covering the given names."""
key = rsa.generate_private_key(public_exponent=65537, key_size=2048)
csr = (
x509.CertificateSigningRequestBuilder()
.subject_name(x509.Name([
x509.NameAttribute(NameOID.COUNTRY_NAME, "IT"),
x509.NameAttribute(NameOID.ORGANIZATION_NAME, "Example Srl"),
x509.NameAttribute(NameOID.COMMON_NAME, common_name),
]))
.add_extension(
x509.SubjectAlternativeName(
[x509.DNSName(name) for name in alt_names]
),
critical=False,
)
.sign(key, hashes.SHA256())
)
return key, csr
key, csr = generate_csr("example.com", ["example.com", "www.example.com"])
# Serialize the CSR to PEM so it can be submitted to a CA
with open("request.csr", "wb") as f:
f.write(csr.public_bytes(serialization.Encoding.PEM))
The resulting request.csr file is what you paste into a CA's issuance form or submit through its API. The private key never leaves your machine, which is precisely the point of the CSR mechanism.
Validating Chains and Checking Expiration
Once you have a certificate, the two questions you will ask most often are: is it still valid in time, and does it chain up to a trusted root? Expiration is the simpler of the two. Because certificate timestamps are timezone-aware in recent versions of the library, always compare against a timezone-aware now.
import datetime
from cryptography import x509
def days_until_expiry(cert: x509.Certificate) -> int:
"""Return the number of whole days until the certificate expires."""
now = datetime.datetime.now(datetime.timezone.utc)
remaining = cert.not_valid_after_utc - now
return remaining.days
def is_currently_valid(cert: x509.Certificate) -> bool:
"""Check whether the certificate is within its validity window."""
now = datetime.datetime.now(datetime.timezone.utc)
return cert.not_valid_before_utc <= now <= cert.not_valid_after_utc
Chain validation is more involved. Recent releases of cryptography include a dedicated verification API that builds and checks a chain against a set of trusted roots. You supply the leaf, any intermediates, and a trust store, and the verifier confirms the signatures, validity periods, and hostname in one call.
from cryptography import x509
from cryptography.x509.verification import PolicyBuilder, Store
def verify_chain(
leaf: x509.Certificate,
intermediates: list[x509.Certificate],
roots: list[x509.Certificate],
hostname: str,
):
"""Verify a leaf certificate against a set of trusted roots."""
store = Store(roots)
builder = PolicyBuilder().store(store)
verifier = builder.build_server_verifier(x509.DNSName(hostname))
# Raises VerificationError if the chain cannot be built or is invalid
verified_chain = verifier.verify(leaf, intermediates)
return verified_chain
If you only need to load the system's trusted roots, the certifi package provides Mozilla's CA bundle in PEM form, which you can parse into a list of certificates and feed into the store. For most public-facing validation, using certifi as the trust anchor matches what browsers do.
Verifying Hostnames Explicitly
When you rely on ssl.create_default_context and leave check_hostname enabled, hostname verification happens automatically during the handshake, and this is what you should do in almost all cases. Occasionally, though, you have a certificate in hand and need to confirm it covers a particular name without opening a connection. You can do this by reading the SAN extension directly and matching against it, including wildcard handling.
import fnmatch
from cryptography import x509
from cryptography.x509.oid import ExtensionOID
def cert_covers_hostname(cert: x509.Certificate, hostname: str) -> bool:
"""Return True if the certificate's SAN entries match the hostname."""
try:
ext = cert.extensions.get_extension_for_oid(
ExtensionOID.SUBJECT_ALTERNATIVE_NAME
)
except x509.ExtensionNotFound:
return False
patterns = ext.value.get_values_for_type(x509.DNSName)
hostname = hostname.lower()
for pattern in patterns:
# fnmatch handles wildcard entries like *.example.com
if fnmatch.fnmatch(hostname, pattern.lower()):
return True
return False
Note that fnmatch is a reasonable approximation but not a fully RFC-compliant matcher: real wildcard rules restrict wildcards to the leftmost label and forbid partial-label matches. For strict validation, prefer the verification API from the previous section, which implements the rules correctly.
Automating Expiration Monitoring
The single most common certificate failure in production is an expired certificate that nobody noticed. A small monitoring script that checks a list of hosts and warns before expiry turns this recurring emergency into a routine task. The following example combines the remote fetch with the expiry check and reports any certificate approaching its deadline.
import ssl
import socket
import datetime
from cryptography import x509
def check_host_expiry(hostname: str, port: int = 443) -> int:
"""Return the number of days until a host's certificate expires."""
context = ssl.create_default_context()
context.check_hostname = False
context.verify_mode = ssl.CERT_NONE
with socket.create_connection((hostname, port), timeout=10) as sock:
with context.wrap_socket(sock, server_hostname=hostname) as tls:
der = tls.getpeercert(binary_form=True)
cert = x509.load_der_x509_certificate(der)
now = datetime.datetime.now(datetime.timezone.utc)
return (cert.not_valid_after_utc - now).days
def monitor(hosts: list[str], warn_days: int = 30) -> None:
"""Check a list of hosts and print those nearing expiration."""
for host in hosts:
try:
days = check_host_expiry(host)
except Exception as exc:
print(f"[ERROR] {host}: {exc}")
continue
if days < 0:
print(f"[EXPIRED] {host} expired {-days} days ago")
elif days <= warn_days:
print(f"[WARN] {host} expires in {days} days")
else:
print(f"[OK] {host} valid for {days} more days")
if __name__ == "__main__":
monitor(["www.python.org", "github.com", "example.com"])
Scheduling this script with cron or a systemd timer, and routing its warnings to email or a chat channel, gives you a dependable early-warning system. The verification is intentionally relaxed here (validation disabled) because the goal is to read the expiry date even from hosts with imperfect chains; if you also want to flag untrusted chains, run a second pass with the default secure context and record any handshake failures.
Integrating with Let's Encrypt and ACME
For public certificates, automation is the norm rather than the exception, and Let's Encrypt has made free, automatically renewable certificates ubiquitous. Let's Encrypt uses the ACME protocol, and while the official certbot client covers most needs, Python has native ACME libraries when you want programmatic control.
The acme package, maintained as part of the Certbot project, lets you drive the full issuance flow from Python: register an account, submit an order for a set of domains, satisfy the challenges that prove domain control, and download the issued certificate. The CSR you learned to build earlier is exactly what you submit to finalize an ACME order.
pip install acme josepy
A complete ACME client is beyond the scope of a single example, but the shape of the workflow is worth internalizing. You generate an account key, create an acme.client.ClientV2 pointed at the directory URL, register the account, place a new order containing your CSR, respond to each authorization's HTTP-01 or DNS-01 challenge, poll until the order is valid, and finally fetch the full-chain certificate. Because renewals reuse the same code path, wrapping this flow in a scheduled job means certificates renew themselves well before expiry, and the monitoring script from the previous section becomes a safety net rather than a primary control.
For most teams the pragmatic split is this: use certbot or a reverse proxy with built-in ACME support for the issuance and renewal itself, and use the Python code shown throughout this article for inspection, validation, and monitoring. That combination covers the entire lifecycle without reinventing the parts that established tools already handle well.
Best Practices
A handful of habits keep certificate management uneventful, which is exactly what you want it to be.
- Never disable verification in production. The secure defaults of
create_default_contextexist for good reason. Disable them only for isolated inspection tasks, and never on a path that carries real traffic. - Protect private keys aggressively. Keys should have restrictive filesystem permissions, live outside version control, and ideally be encrypted at rest. A leaked private key compromises the certificate entirely.
- Always populate Subject Alternative Names. The common name alone is ignored by modern clients. Every DNS name the certificate must serve belongs in the SAN extension.
- Prefer timezone-aware timestamps. Use the
_utcvariants of the validity properties and compare against timezone-aware datetimes to avoid subtle off-by-hours errors. - Automate renewal, then monitor it. Automated issuance via ACME should be the primary mechanism; an independent expiry monitor is the backstop that catches the cases where automation silently fails.
- Validate the full chain, not just the leaf. A certificate that is individually valid can still fail in clients if intermediates are missing. Use the verification API to confirm the chain builds to a trusted root.
With the standard library for connection-level work and cryptography for everything structural, Python gives you full command over the certificate lifecycle. The same building blocks scale from inspecting a single host during a debugging session to running fleet-wide expiration monitoring and driving automated ACME renewals, letting you treat certificates as a managed, observable part of your infrastructure rather than a recurring surprise.