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") import socketimport sslfrom 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()) import socketimport sslfrom 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
x509.load_pem_x509_certificateparses the file (load_der_x509_certificatefor binary.der/.cer);not_valid_after_utcis a timezone-awaredatetime, so subtractingdatetime.now(timezone.utc)gives the time left directly.- For a server,
ssl.create_default_context(cafile=...)verifies the chain and the host name; withoutcafileit uses the system roots.server_hostnamesends SNI, so the server picks the right certificate. getpeercert()returns a dict whosenotAfteris text (Dec 31 23:59:59 2035 GMT);ssl.cert_time_to_secondsturns it into a timestamp.- The run uses a test chain whose leaf is valid until
2035-12-31T23:59:59Zand an expired one (2025-03-31T23:59:59Z), served byopenssl s_serverin the same container.
Gotchas
- A verifying connection to an expired certificate fails before you get the date:
ssl.SSLCertVerificationErrorwithverify_messagecertificate 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, takegetpeercert(binary_form=True)and parse the DER withcryptography, asserver_not_after_unverifieddoes. not_valid_after(without_utc) is a naivedatetime(tzinfo: None) and raisesCryptographyDeprecationWarning; comparing it withdatetime.now(timezone.utc)raisesTypeError: can't compare offset-naive and offset-aware datetimes. Usenot_valid_after_utc.timedelta.daysrounds down: an expired certificate shows a negative number of days (-568in the run), sodays_left(...) < 30also catches certificates that have already expired.