PKCS#12 client-certificate (mTLS) sessions for httpx2 and httpx.
httpx-pki gives you an httpx.Client (and httpx.AsyncClient) subclass with a
client certificate already mounted, so mutual-TLS endpoints "just work":
from httpx_pki import PKIClient
with PKIClient("client.p12", password="secret") as client:
resp = client.get("https://mtls.example.com/")
print(resp.status_code)π Full documentation
httpx deprecated its cert= argument in 0.28 β a design httpx2 keeps β in
favor of building an ssl.SSLContext yourself, which stdlib ssl can't do
from PKCS#12 or in-memory bytes. httpx-pki is that missing piece.
pip install httpx-pkiRequires Python 3.10+. httpx2 comes with it, along with cryptography,
truststore, and certifi.
Prefer the original httpx? It stays fully supported β install with --no-deps
so httpx2 isn't pulled in. See
Install and
Backends.
Certificate files come with all sorts of extensions β .p12, .pfx, .pem,
.crt, .tls β but an extension is just a name. httpx-pki detects the
encoding from the bytes, so you can point it at whatever your PKI team sent
you:
from httpx_pki import PKIClient
# PKCS#12 bundle β key + cert + chain in one blob
PKIClient("client.p12", password="secret")
# PEM bundle β key + cert(s) in one file, any block order
PKIClient("client.pem")
# Raw bytes you already have in hand
PKIClient(p12_bytes, password=b"secret")
# Separate certificate and key, PEM or DER
PKIClient.from_key_pair("client.crt", "client.key")
# ...with intermediates, as PEM or PKCS#7
PKIClient.from_key_pair("client.crt", "client.key", chain="chain.p7b")
# The Windows certificate store (Windows only)
PKIClient.from_windows_cert_store(name="Acme Corp")
# The macOS keychain (macOS only)
PKIClient.from_macos_keychain(name="Acme Corp")
# Configured entirely by environment variables
PKIClient.from_env()A CA rarely sends one file. inventory reads the folder, says what each file
actually is, pairs the keys with their certificates, and prints the call each
pairing amounts to:
$ python -m httpx_pki inventory ./corp-exportINVENTORY corp-export β 7 files, 2 identities
IDENTITY svc-client RSA-2048 expires 2027-01-15
bundle corp.p12 (password #1)
chain corp-issuing-ca.crt
β PKIClient("corp.p12", password=..., chain="corp-issuing-ca.crt")
IDENTITY svc-client RSA-2048 expires 2027-01-15
certificate svc-client.pem
private key svc-client.key (encrypted β opened with password #2)
chain corp-issuing-ca.crt
same certificate as corp.p12
β from_key_pair(certificate="svc-client.pem", private_key="svc-client.key", password=..., chain="corp-issuing-ca.crt")
LOCKED old-2025.pem β 1 certificate, plus 1 encrypted private key none of the given passwords open
NOTES cert-details.txt β human-readable dump; fingerprint matches corp.p12 (not loadable)
svc-client.csr β certificate request for the key of corp.p12 (issuance artifact, not loadable)
Nothing is skipped: a file no password opens is reported as locked, not dropped. It classifies and pairs β it never builds a session for you, because a folder like that usually holds more than one answer.
β Taking inventory of a folder
from httpx_pki import AsyncPKIClient
async with AsyncPKIClient("client.p12", password="secret") as client:
resp = await client.get("https://mtls.example.com/")A PKCS#12 or PEM bundle can hold more than one identity β a dual key pair from
AD key archival, or a renewed certificate kept beside the one it replaces.
cryptography can't express that: it returns the first key and leaves the other
identity's certificate looking like a chain certificate. httpx-pki reads the
structure itself, so you can inspect and select:
from httpx_pki import PKIClient, list_identities, for_mtls
list_identities("corp.p12", password="secret") # see what's in there
# Usually all you need: the identity that is valid now and can do client auth
PKIClient("corp.p12", password="secret", identity=for_mtls)
# Or pick one yourself
PKIClient("corp.p12", password="secret", key_usage="digital_signature")
PKIClient("corp.p12", password="secret", identity="Signature")Loading a multi-identity bundle without a selector raises rather than guessing.
β Choosing the right certificate
Your client certificate and the server's are independent. verify=True (the
default) uses the OS trust store, so corporate CAs distributed by group
policy or MDM work out of the box:
PKIClient("client.p12", password="secret", verify="/etc/ssl/internal-ca.pem")
PKIClient("client.p12", password="secret", verify="certifi")Naming a bundle replaces the default trust. To keep it and add your own β the usual shape for a service that talks to internal and public endpoints both β pass a list:
PKIClient("client.p12", password="secret",
verify=["system", "/etc/pki/internal-root.pem"])β Server trust
Certificates keep getting shorter-lived. Warn early, reload automatically, or fail loudly:
from datetime import timedelta
PKIClient(
"/etc/certs/client.pem",
auto_reload=True, # pick up cert-manager rotations
strict_validity=True, # fail clearly, not at handshake
warn_if_expires_within=timedelta(days=7),
)client.cn # 'corp-user'
client.not_valid_after # datetime (UTC)
client.is_expired # bool
client.cert_info() # CertInfo: subject, issuer, fingerprints, usages, SANsexplain() takes the same arguments as the constructors and reports what it
would do instead of doing it β what the source holds, what it would present,
what it would trust, and what would stop it working:
$ python -m httpx_pki explain corp.p12 --verify internal-ca.pemcorp.p12 β PKCS#12, 1 identity, 1 chain certificate
PRESENTS svc-client
valid 2026-01-15 β 2027-01-15 (159 days left)
ext usage client_auth
CHAIN svc-client
ββ Corp Issuing CA [verified]
ββ Corp Root [NOT SUPPLIED; trust anchor, need not be sent]
TRUSTS internal-ca.pem β 1 anchor: Corp Root
PROBLEMS none
It works when loading does not β several identities with no selector, or a
missing password, produce a report rather than an exception. client.explain()
does the same for a live session, and the CLI exits non-zero when there are
problems, so it works as a CI check.
β Explaining a whole configuration
Don't want the client wrapper? build_ssl_context() gives you the hard part,
ready for a plain httpx.Client or a custom transport:
import httpx
from httpx_pki import build_ssl_context
ctx = build_ssl_context("client.p12", password="secret")
client = httpx.Client(verify=ctx)transport= makes httpx ignore verify= β put the context
on the inner transport, not the client.
β Advanced usage
httpx_pki.testing mints throwaway certificates, including multi-identity
bundles that nothing else readily produces:
from httpx_pki.testing import make_ca, make_client_cert
ca = make_ca()
bundle = make_client_cert("svc-client", ca=ca, dns_names=["svc.internal"])
expired = make_client_cert("old", ca=ca, expired=True)β Testing helpers
To support pickling, a client stores its certificate material and rebuilds the
SSL context on unpickle. The pickle therefore contains the decrypted private
key in cleartext β treat it as a secret. repr() never reveals key material.
Passwords are not retained, with one exception: enabling auto_reload keeps the
password on the client so unattended reloads can decrypt the rotated source.
β Security notes Β· SECURITY.md
Stdlib ssl can't load PKCS#12 or in-memory key material, so httpx-pki uses
cryptography to extract the key and certificates,
stages them where OpenSSL can read them, and passes the resulting
ssl.SSLContext to httpx via verify=.
On Linux the decrypted key never touches disk β it's staged in an anonymous
memfd that OpenSSL reads through /proc/self/fd and that ceases to exist when
closed. Elsewhere it's a 0600 temp file, deleted immediately after loading.
β How it works
Scoped to credentials whose private key can be exported into memory. Not
supported: PKCS#11 / smartcards / HSMs / TPMs (incompatible with stdlib
ssl, which needs the raw key bytes), Java keystores (convert to PKCS#12
with keytool), workload-identity protocol clients (point auto_reload at
the files they write), and OCSP / CRL revocation (nothing in stdlib ssl to
build on).
β Non-goals
Released to PyPI exclusively from GitHub Actions via
Trusted Publishing (OIDC β no
long-lived tokens) with PEP 740
attestations, from a tagged commit whose version is verified against
__version__ at build time. All Actions are pinned to full commit SHAs.
Install via a lockfile that records hashes, as with any security-sensitive dependency.
β Supply chain Β· Changelog
MIT