mrkeyoor.com_
Thu 06 Aug 00:58 UTC
PyPISecurityupdated 05 Aug 2026

pyOpenSSL

pyOpenSSL is a thin Python wrapper over a subset of the OpenSSL library. Its useful half is OpenSSL.SSL: Context and Connection objects that give you direct control over a TLS handshake, including ALPN negotiation, SNI callbacks, per-connection verification, session reuse, OCSP stapling, and a memory BIO mode that lets you drive TLS without a socket at all. Its other half, OpenSSL.crypto, used to be the way Python people parsed and built X.509 certificates, and the maintainers are steadily removing it: the same organization publishes cryptography, and the README states plainly that if you are using pyOpenSSL for anything other than making a TLS connection you should move to cryptography and drop the dependency.

Verdict

Keep it for the TLS control surface the standard library does not give you, especially memory BIO mode and handshake callbacks. Move every certificate and key operation to cryptography now, because pyOpenSSL is removing that half of itself release by release and telling you so in its own README.

API stability3/5The SSL half has been steady for years, but the crypto half is being dismantled on a published schedule: X509Req and dump_certificate_request went in 26.3.0, X509Extension in 26.2.0, and X509Name plus the mutating X509 methods are deprecated now. CalVer with a strict policy means you get warning, not immunity.
Docs3/5pyopenssl.org is a complete API reference with per-method notes, but there is almost no narrative material, no migration guide off the deprecated crypto APIs, and the most prominent piece of documentation is the README telling you to use a different library.
Maintenance4/5Pushed August 2026 with 66 open issues (88 counting PRs) and releases roughly monthly through 26.x, run by the Python Cryptographic Authority. CVE-2026-40475, an X509Name truncation bug, was fixed and credited in 26.1.0, so security reports do get handled.
Ecosystem4/5Around 69 million weekly downloads, mostly transitive through Twisted, eventlet, cloud SDKs, and older requests security extras. New projects rarely add it on purpose, and urllib3 2.x no longer needs its TLS injection.

Use it if

  • You need TLS knobs the standard library ssl module does not expose, such as an SNI callback that swaps certificates per hostname, OCSP stapling callbacks, or per-connection verify overrides
  • You are driving TLS without a socket: the memory BIO mode (Connection(ctx, None) plus bio_read and bio_write) is how Twisted, eventlet, and async frameworks do TLS over their own transports
  • You already depend on something that requires it, such as Twisted's TLS support, and you want to use the same Context objects rather than bolt a second TLS stack onto the process
  • You need to interoperate with a specific OpenSSL behaviour, for example a cipher string, a legacy protocol version, or a keylog callback for decrypting captures in Wireshark
Skip it if

Setup reality

pip install pyOpenSSL needs Python 3.9 or newer and pulls cryptography, which ships wheels with a statically linked OpenSSL for the common platforms. That is where the friction lives: on an unusual architecture or an old pip, cryptography falls back to a source build that wants a Rust toolchain and OpenSSL headers, and the failure message points at cryptography rather than at you. The version ceiling matters too, since a resolver that wants cryptography 51 will simply refuse to install this. Once running, watch for behaviour that changed recently: as of 26.2.0 mutating a Context after it has created a Connection is an error rather than a warning, and 26.3.0 made reusing a Session across Contexts raise ValueError. Deprecation warnings are not cosmetic here, because the deprecated names are actually deleted a release or two later.

Patterns

Make a verified TLS client connectiontls-client-connection

import socket
from OpenSSL import SSL

ctx = SSL.Context(SSL.TLS_CLIENT_METHOD)
ctx.set_verify(SSL.VERIFY_PEER | SSL.VERIFY_FAIL_IF_NO_PEER_CERT)
ctx.set_default_verify_paths()
ctx.set_min_proto_version(SSL.TLS1_2_VERSION)

sock = socket.create_connection(("example.com", 443))
conn = SSL.Connection(ctx, sock)
conn.set_tlsext_host_name(b"example.com")   # SNI
conn.set_connect_state()
conn.do_handshake()

Chain verification alone is not authentication. Without a hostname check, any certificate signed by any CA in your trust store passes, so pair this with the next pattern.

Check the hostname against the certificateverify-hostname

from cryptography.x509 import DNSName, ExtensionOID

cert = conn.get_peer_certificate(as_cryptography=True)
san = cert.extensions.get_extension_for_oid(
    ExtensionOID.SUBJECT_ALTERNATIVE_NAME).value
names = san.get_values_for_type(DNSName)
if not any(n == "example.com" or
           (n.startswith("*.") and "example.com".endswith(n[1:]))
           for n in names):
    conn.close()
    raise SSL.Error("hostname mismatch")

Wildcard matching, internationalized names, and IP SANs have real edge cases. The service-identity package implements the rules properly and is what Twisted uses; hand-rolling this is only acceptable for a fixed internal hostname.

Build a server context with a certificate and keytls-server-context

from OpenSSL import SSL

ctx = SSL.Context(SSL.TLS_SERVER_METHOD)
ctx.use_certificate_chain_file("fullchain.pem")
ctx.use_privatekey_file("privkey.pem")
ctx.check_privatekey()          # raises if key does not match cert
ctx.set_min_proto_version(SSL.TLS1_2_VERSION)
ctx.set_options(SSL.OP_NO_COMPRESSION | SSL.OP_CIPHER_SERVER_PREFERENCE)

As of 26.2.0 you cannot mutate a Context after it has created its first Connection; doing so raises. Configure the Context fully at startup and treat it as frozen afterwards.

Serve different certificates per hostnamesni-callback

contexts = {b"a.example.com": ctx_a, b"b.example.com": ctx_b}

def pick_cert(connection):
    name = connection.get_servername()
    chosen = contexts.get(name)
    if chosen is not None:
        connection.set_context(chosen)

main_ctx.set_tlsext_servername_callback(pick_cert)

get_servername returns bytes or None, because a client is not required to send SNI. This callback is one of the main reasons to use pyOpenSSL over the standard library on the server side.

Negotiate HTTP/2 with ALPNalpn-negotiation

# client
client_ctx.set_alpn_protos([b"h2", b"http/1.1"])
# ... after handshake
print(conn.get_alpn_proto_negotiated())   # b'h2'

# server
def choose(connection, protos):
    return b"h2" if b"h2" in protos else b"http/1.1"

server_ctx.set_alpn_select_callback(choose)

Protocol identifiers are bytes, not strings. If the server callback returns a protocol the client did not offer, OpenSSL fails the handshake rather than falling back.

Run TLS without a socket using a memory BIOmemory-bio

from OpenSSL import SSL

conn = SSL.Connection(ctx, None)   # no socket
conn.set_connect_state()

while True:
    try:
        conn.do_handshake()
        break
    except SSL.WantReadError:
        transport.send(conn.bio_read(65536))     # flush outbound bytes
        conn.bio_write(transport.recv(65536))    # feed inbound bytes

This is how async frameworks do TLS over their own transports. Always drain bio_read after every operation, including after bio_write, or the handshake stalls waiting for bytes you are still holding.

Handle WantRead and WantWrite on a non-blocking socketnonblocking-io

import select
from OpenSSL import SSL

sock.setblocking(False)
while True:
    try:
        data = conn.recv(4096)
        break
    except SSL.WantReadError:
        select.select([sock], [], [], 5)
    except SSL.WantWriteError:
        select.select([], [sock], [], 5)
    except SSL.ZeroReturnError:
        data = b""      # clean TLS shutdown from the peer
        break

ZeroReturnError means an orderly close_notify and is not an error condition. A WantWriteError on a read is normal during renegotiation, so handle both directions on both operations.

Get the peer certificate as a cryptography objectinspect-peer-certificate

cert = conn.get_peer_certificate(as_cryptography=True)
print(cert.subject.rfc4514_string())
print(cert.not_valid_after_utc)

chain = conn.get_verified_chain(as_cryptography=True)
for c in chain or []:
    print(c.issuer.rfc4514_string())

Always pass as_cryptography=True. The default returns the legacy OpenSSL.crypto.X509 object whose accessors are being deprecated, and get_verified_chain returns None when verification did not succeed.

Validate a certificate chain without a connectionverify-chain-offline

from cryptography import x509
from OpenSSL.crypto import X509, X509Store, X509StoreContext, X509StoreContextError

store = X509Store()
store.add_cert(X509.from_cryptography(x509.load_pem_x509_certificate(root_pem)))
leaf = X509.from_cryptography(x509.load_pem_x509_certificate(leaf_pem))

try:
    X509StoreContext(store, leaf, chain=[intermediate]).verify_certificate()
except X509StoreContextError as exc:
    print("invalid:", exc)

Load and build certificates with cryptography and convert at the boundary with from_cryptography. cryptography's own x509.verification module now covers most offline chain validation, so check whether you need this at all.

Dump TLS secrets for Wiresharkkeylog-callback

def keylog(connection, line):
    with open("/tmp/sslkeys.log", "ab") as fh:
        fh.write(line + b"\n")

ctx.set_keylog_callback(keylog)

Point Wireshark at that file to decrypt the capture. This writes the session secrets in plaintext, so gate it behind a debug flag and never enable it on a production host.

Require and read a client certificateclient-certificates

def verify_cb(conn, cert, errnum, depth, ok):
    if not ok:
        print("chain error", errnum, "at depth", depth)
    return bool(ok)

server_ctx.set_verify(
    SSL.VERIFY_PEER | SSL.VERIFY_FAIL_IF_NO_PEER_CERT | SSL.VERIFY_CLIENT_ONCE,
    verify_cb,
)
server_ctx.load_verify_locations("client-ca.pem")

The callback fires once per certificate in the chain, deepest first, and returning a truthy value overrides OpenSSL's own verdict. Returning True unconditionally, which appears in a lot of copied code, turns verification off entirely.

Replace deprecated OpenSSL.crypto callsmigrate-off-crypto

# old
from OpenSSL import crypto
cert = crypto.load_certificate(crypto.FILETYPE_PEM, pem_bytes)
subject = cert.get_subject().CN

# current
from cryptography import x509
from cryptography.x509.oid import NameOID
cert = x509.load_pem_x509_certificate(pem_bytes)
subject = cert.subject.get_attributes_for_oid(NameOID.COMMON_NAME)[0].value

Certificate signing requests are already gone: X509Req and load_certificate_request were removed in 26.3.0, and x509.CertificateSigningRequestBuilder is the replacement. Run your test suite with -W error::DeprecationWarning to find the rest before an upgrade does it for you.

Alternatives

PackageRegistryPick it when
cryptographyPyPIYou are doing anything with certificates, keys, signing, or hashing rather than running a live TLS handshake; this is the maintainers' own recommendation.
truststorePyPIYou only wanted the operating system's certificate store behind the standard library ssl module, which is the actual reason many projects reached for pyOpenSSL.
trustmePyPIYou were generating throwaway certificates for tests and used OpenSSL.crypto to do it; this makes a CA and leaf certs in two lines.