Skip to main content

httpx-pki

CI codecov PyPI Python versions Docs License: MIT Checked with mypy

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

Purpose

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.

Install

pip install httpx-pki

Requires 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.

Whatever you were handed, there's a one-liner for it

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()

Loading certificates

Handed a whole folder?

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-export
INVENTORY  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

Async

from httpx_pki import AsyncPKIClient

async with AsyncPKIClient("client.p12", password="secret") as client:
    resp = await client.get("https://mtls.example.com/")

One file, several certificates

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

Server trust

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

Expiry and rotation

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),
)

Expiry and rotation

Inspecting what's mounted

client.cn                 # 'corp-user'
client.not_valid_after    # datetime (UTC)
client.is_expired         # bool
client.cert_info()        # CertInfo: subject, issuer, fingerprints, usages, SANs

Inspecting a certificate

Why won't the handshake work?

explain() 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.pem
corp.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

Just the SSL context

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)

⚠️ Passing a custom transport= makes httpx ignore verify= — put the context on the inner transport, not the client.

Advanced usage

Testing helpers

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

⚠️ Security note on pickling

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

How it works

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

Non-goals

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

Supply chain

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

License

MIT

Download files

Download the file for your platform. If you're not sure which to choose, learn more about installing packages.

Source Distribution

httpx_pki-0.9.1.tar.gz (227.9 kB view details)

Uploaded Source

Built Distribution

If you're not sure about the file name format, learn more about wheel file names.

httpx_pki-0.9.1-py3-none-any.whl (111.9 kB view details)

Uploaded Python 3

File details

Details for the file httpx_pki-0.9.1.tar.gz.

File metadata

  • Download URL: httpx_pki-0.9.1.tar.gz
  • Upload date:
  • Size: 227.9 kB
  • Tags: Source
  • Uploaded using Trusted Publishing? Yes
  • Uploaded via: twine/7.0.0 CPython/3.13.14

File hashes

Hashes for httpx_pki-0.9.1.tar.gz
Algorithm Hash digest
SHA256 93cdbe0ee18f971f2e93e69e700298b347d933eb850e7b38f12a3b706c6c5964
MD5 3f8a7d85dddbcfcba097e1d4406b6a8f
BLAKE2b-256 141ebb1cb21d4107a51785248816f6a0b19cdf2fab8ad3edab7b697e889281c1

See more details on using hashes here.

Provenance

The following attestation bundles were made for httpx_pki-0.9.1.tar.gz:

Publisher: publish.yml on ccbest/httpx-pki

Attestations: Values shown here reflect the state when the release was signed and may no longer be current.

File details

Details for the file httpx_pki-0.9.1-py3-none-any.whl.

File metadata

  • Download URL: httpx_pki-0.9.1-py3-none-any.whl
  • Upload date:
  • Size: 111.9 kB
  • Tags: Python 3
  • Uploaded using Trusted Publishing? Yes
  • Uploaded via: twine/7.0.0 CPython/3.13.14

File hashes

Hashes for httpx_pki-0.9.1-py3-none-any.whl
Algorithm Hash digest
SHA256 5008ae913cac9e835149e04b5b8b1bbd0e7e11727dd5ea1677e58aa4dfe6b7ec
MD5 d144d831fd77c58c0110060e4594cf01
BLAKE2b-256 a547fde5e4ae8f3eba8a957c85abdcae0b4790e0f833f983e736f6fb2d5fa3b0

See more details on using hashes here.

Provenance

The following attestation bundles were made for httpx_pki-0.9.1-py3-none-any.whl:

Publisher: publish.yml on ccbest/httpx-pki

Attestations: Values shown here reflect the state when the release was signed and may no longer be current.

Release history Release notifications | RSS feed

This release

0.9.1 This release

2 files

0.9.0

2 files

0.8.0

2 files

0.7.0

2 files

0.6.0

2 files

0.5.0

2 files

0.4.0

2 files

0.3.0

2 files

0.2.0

2 files

0.1.0

2 files

Supported by

AWS Cloud computing and Security Sponsor Datadog Monitoring Depot Continuous Integration Fastly CDN Google Download Analytics Sentry Error logging StatusPage Status page