Reusing SSL Contexts in Async Clients¶
Before a TLS handshake there is an ssl.SSLContext: the object that holds trusted CA certificates, protocol versions, cipher settings, client certificates and verification rules. Building one is not free, because loading a trust store means parsing every CA certificate in it — and libraries that build a context implicitly do so every time you create a client. Measured on Python 3.14 with OpenSSL 3.5.5: ssl.create_default_context() took 2.68 ms at the median, and the same with certifi's bundle 2.70 ms, while a bare SSLContext(PROTOCOL_TLS_CLIENT) with no trust store took 0.057 ms. Creating an httpx.AsyncClient() took 3.06 ms, almost all of it building its context; passing verify= an existing context brought it to 0.02 ms. An aiohttp.ClientSession() took 0.08 ms, because aiohttp builds its default contexts once and caches them. This guide builds contexts deliberately, once, with the settings production needs.
Prerequisites¶
- Python 3.11+;
pip install httpx aiohttp certififor the client examples. - TLS basics in asyncio, from adding TLS to asyncio streams with SSL contexts.
- The topic overview, TLS & DNS.
1. Measure what a context costs¶
Time each way of building a context, and each client constructor that builds one for you:
import ssl, statistics, time
import certifi, httpx
def median_ms(fn, n=200):
xs = []
for _ in range(n):
t = time.perf_counter(); fn(); xs.append(time.perf_counter() - t)
return statistics.median(xs) * 1e3
print(median_ms(ssl.create_default_context)) # 2.68
print(median_ms(lambda: ssl.create_default_context(cafile=certifi.where()))) # 2.70
print(median_ms(lambda: ssl.SSLContext(ssl.PROTOCOL_TLS_CLIENT))) # 0.057
The difference between the bare context and the default one is the trust store: on the test system, the default paths pointed at /usr/lib/ssl/cert.pem with 121 CA certificates, all parsed on every call. Clients that construct a context per instance inherit that cost: httpx.AsyncClient() measured 3.06 ms. In a request path — a client created per request, per job, per tenant — 3 ms of CPU on the event loop per creation adds up.
Verify: profile client construction in your service; if load_verify_locations or create_default_context appears in request-path profiles, contexts are being rebuilt.
2. Build one context per configuration, at startup¶
A context is safe to share across connections, tasks and clients; create it once per distinct configuration and pass it everywhere:
import ssl
import certifi
# Public internet APIs: verify against a known bundle
PUBLIC = ssl.create_default_context(cafile=certifi.where())
PUBLIC.minimum_version = ssl.TLSVersion.TLSv1_2
# Internal services: a private CA, and a client certificate for mutual TLS
INTERNAL = ssl.create_default_context(cafile="/etc/pki/internal-ca.pem")
INTERNAL.load_cert_chain("/etc/pki/client.pem", "/etc/pki/client.key")
INTERNAL.minimum_version = ssl.TLSVersion.TLSv1_3
Each context encodes a trust decision — which CAs, which protocol versions, whether this side presents a certificate — so naming them by purpose makes those decisions reviewable. Contexts are effectively read-only once connections use them; mutating a shared context at runtime (loading new certificates, changing verify modes) affects every subsequent handshake on every client that holds it, which is a feature for certificate rotation and a hazard otherwise, as the certificate reload guide shows.
Verify: the codebase has a small, named set of contexts created at import or startup, and no create_default_context call in request paths.
3. Pass contexts to every client¶
Each library accepts a context in its own place:
import httpx, aiohttp, asyncio
http = httpx.AsyncClient(verify=PUBLIC, timeout=httpx.Timeout(10.0, connect=3.0)) # 0.02 ms
connector = aiohttp.TCPConnector(ssl=INTERNAL)
internal = aiohttp.ClientSession(connector=connector)
reader, writer = await asyncio.open_connection("db.internal", 5433, ssl=INTERNAL,
server_hostname="db.internal")
Measured: httpx.AsyncClient(verify=shared_ctx) constructed in 0.02 ms, against 3.06 ms when it built its own. Sharing one context also keeps every client's trust and protocol settings identical, so a change to the policy is made in one place. The same applies to database drivers: asyncpg accepts ssl= as a context, and passing one avoids each pool connection rebuilding its own.
Verify: grep for client constructors without an explicit context argument; each one builds its own.
4. Use the operating system's trust store when it matters¶
certifi ships Mozilla's CA bundle with the package, so it is consistent across machines but ignores certificates installed by the organization — internal CAs, inspection proxies. The truststore package lets Python's ssl use the operating system's verifier instead:
import ssl
import truststore
OS_TRUST = truststore.SSLContext(ssl.PROTOCOL_TLS_CLIENT) # verifies with the OS's store
http = httpx.AsyncClient(verify=OS_TRUST)
The choice is a policy one: a bundled store makes behaviour identical everywhere and independent of host configuration; the OS store follows the host's administration, including corporate CAs and revocations. Whichever you choose, choose it once, in the shared context, rather than letting each library's default decide. Building any of them is a startup cost, measured above at a few milliseconds, not a per-request one.
Verify: a request to an internal service signed by your private CA succeeds through the configured context, and fails through a context that does not trust that CA.
5. Never disable verification to make an error go away¶
The tempting fix for CERTIFICATE_VERIFY_FAILED is a context that does not verify:
# Do not ship this
insecure = ssl.create_default_context()
insecure.check_hostname = False
insecure.verify_mode = ssl.CERT_NONE
# Fix the trust instead: add the CA that signed the server's certificate
fixed = ssl.create_default_context(cafile="/etc/pki/internal-ca.pem")
A context with CERT_NONE encrypts the connection but accepts any certificate, so anyone on the path can impersonate the server. Verification errors almost always mean the context does not trust the right CA — an internal CA missing from a bundled store, an intermediate certificate missing from the server's chain — and the fix belongs in the context's configuration. Keeping contexts centralized, as in step 2, makes a disabled verification stand out in review instead of hiding in one client constructor.
Verify: grep finds no CERT_NONE or verify=False outside test code.
Verification¶
SSL contexts are handled well when:
- A small set of named contexts is built at startup, one per trust decision.
- Every client receives a context explicitly; none builds its own in a request path.
- The trust store is a deliberate choice: a bundle, the OS store, or a private CA.
- No production code disables verification.
Diagnostic Hook: count SSLContext objects alive in the process (sum(isinstance(o, ssl.SSLContext) for o in gc.get_objects())) after a warm-up period. The number should equal your named contexts plus a few library defaults; a count that grows with traffic means something builds a context per client or per request.
Pitfalls & edge cases¶
httpx.AsyncClient()per request. Measured: 3.06 ms each, mostly building an SSL context.- One context per client in a request path. Each costs about 2.7 ms to build.
- Mutating a shared context casually. Changes apply to every later handshake.
CERT_NONEto silence errors. It disables authentication of the server.
Frequently Asked Questions¶
Is ssl.create_default_context() expensive?
It took about 2.7 ms in testing, almost all of it loading and parsing the CA trust store; a bare SSLContext without a trust store took 0.057 ms. Build contexts once and reuse them.
Why is creating an httpx.AsyncClient slow?
It builds a new SSL context with the full trust store: 3.06 ms per client in testing. Passing verify= an existing context brought construction to 0.02 ms.
Can an SSLContext be shared between asyncio connections and clients?
Yes. Contexts are designed to be shared across connections, tasks and clients. Avoid mutating one after connections start using it unless that is the intent, as with certificate reloads.
Should I use certifi or the system trust store?
certifi gives the same Mozilla bundle everywhere; the OS store (via truststore) follows the host's configuration, including private and corporate CAs. Pick one deliberately and build it into a shared context.
Related¶
- TLS & DNS — up to the topic overview.
- Reloading TLS certificates without restarting — changing a server's context safely.
- Network I/O & Protocol Handling — the section overview.