Skip to content
ecdsa.com

Certificate expiry · Python

How to check SSL certificate expiration date in Python

To check SSL certificate expiration date in Python, read not_valid_after_utc from a certificate file with the cryptography package, or connect with the standard ssl module and read notAfter from the server's verified certificate. This recipe does both against a local test server, and shows how to read the date of a certificate that has already expired — when a verifying connection refuses to complete.

Tested with cryptography 50.0.2 (Python 3.14.8), Python ssl 3.14.8 (OpenSSL 3.5.7), and OpenSSL 3.5.7 (openssl s_server as the local TLS servers).

expiry_file.py — notAfter and days left for a PEM file
from datetime import datetime, timezone
from cryptography import x509
def days_left(path: str) -> int:
with open(path, "rb") as f:
cert = x509.load_pem_x509_certificate(f.read()) # load_der_x509_certificate for .der/.cer
not_after = cert.not_valid_after_utc # timezone-aware, in UTC
left = (not_after - datetime.now(timezone.utc)).days
print(f"{path}: notAfter {not_after.isoformat()}, {left} days left")
return left
if __name__ == "__main__":
if days_left("leaf.pem") < 30:
print("renew now")
expiry_server.py — the expiry of the certificate a TLS server presents (standard library only)
import socket
import ssl
from datetime import datetime, timezone
def server_not_after(host: str, port: int = 443, cafile: str | None = None) -> datetime:
context = ssl.create_default_context(cafile=cafile) # verifies the chain and the host name
with socket.create_connection((host, port), timeout=10) as sock:
with context.wrap_socket(sock, server_hostname=host) as tls: # server_hostname also sets SNI
cert = tls.getpeercert() # a dict with "notAfter": "Dec 31 23:59:59 2035 GMT"
return datetime.fromtimestamp(ssl.cert_time_to_seconds(cert["notAfter"]), timezone.utc)
if __name__ == "__main__":
print("api.example.com:", server_not_after("api.example.com", 8443, cafile="root.pem").isoformat())
expiry_unverified.py — read the date even when the certificate no longer verifies
import socket
import ssl
from datetime import datetime
from cryptography import x509
def server_not_after_unverified(host: str, port: int = 443) -> datetime:
"""Reads the date even from an expired or untrusted certificate — for monitoring only."""
context = ssl.create_default_context()
context.check_hostname = False
context.verify_mode = ssl.CERT_NONE # getpeercert() now returns {}, binary_form still works
with socket.create_connection((host, port), timeout=10) as sock:
with context.wrap_socket(sock, server_hostname=host) as tls:
der = tls.getpeercert(binary_form=True)
return x509.load_der_x509_certificate(der).not_valid_after_utc

How it works

  1. x509.load_pem_x509_certificate parses the file (load_der_x509_certificate for binary .der/.cer); not_valid_after_utc is a timezone-aware datetime, so subtracting datetime.now(timezone.utc) gives the time left directly.
  2. For a server, ssl.create_default_context(cafile=...) verifies the chain and the host name; without cafile it uses the system roots. server_hostname sends SNI, so the server picks the right certificate.
  3. getpeercert() returns a dict whose notAfter is text (Dec 31 23:59:59 2035 GMT); ssl.cert_time_to_seconds turns it into a timestamp.
  4. The run uses a test chain whose leaf is valid until 2035-12-31T23:59:59Z and an expired one (2025-03-31T23:59:59Z), served by openssl s_server in the same container.

Gotchas

  • A verifying connection to an expired certificate fails before you get the date: ssl.SSLCertVerificationError with verify_message certificate has expired. Monitoring code should catch it and report it as what it is — an expired certificate.
  • With verify_mode = CERT_NONE, getpeercert() returns an empty dict {}. To inspect an unverified certificate, take getpeercert(binary_form=True) and parse the DER with cryptography, as server_not_after_unverified does.
  • not_valid_after (without _utc) is a naive datetime (tzinfo: None) and raises CryptographyDeprecationWarning; comparing it with datetime.now(timezone.utc) raises TypeError: can't compare offset-naive and offset-aware datetimes. Use not_valid_after_utc.
  • timedelta.days rounds down: an expired certificate shows a negative number of days (-568 in the run), so days_left(...) < 30 also catches certificates that have already expired.

Related recipes