Skip to content

Instantly share code, notes, and snippets.

@mdehling
Created March 10, 2026 15:15
Show Gist options
  • Select an option

  • Save mdehling/350fc63d286a31b2653aef1362c6b0f5 to your computer and use it in GitHub Desktop.

Select an option

Save mdehling/350fc63d286a31b2653aef1362c6b0f5 to your computer and use it in GitHub Desktop.
Python 3.13+ rejects CA certs missing Key Usage extension (SSL inspection proxy workaround)

Python 3.13+ Rejects CA Certificates Missing the Key Usage Extension

Summary

Python 3.13 enables the VERIFY_X509_STRICT flag by default in ssl.create_default_context(). This enforces RFC 5280, which requires CA certificates to include the Key Usage X.509v3 extension with at least keyCertSign and cRLSign. Any CA certificate that omits this extension is now rejected at the TLS handshake with:

ssl.SSLCertVerificationError: [SSL: CERTIFICATE_VERIFY_FAILED]
  certificate verify failed: CA cert does not include key usage extension (_ssl.c:1000)

This affects corporate environments where an SSL-inspection proxy (e.g., Netskope, Zscaler, BlueCoat) injects its own root CA into the trust chain. If that root CA was issued without the Key Usage extension, all Python 3.13+ HTTPS connections through the proxy will fail.

Python 3.12 and below, curl, Node.js, Go, and browsers are not affected — they do not enforce this particular constraint.

Who is affected

  • Any machine running Python >= 3.13 whose HTTPS traffic passes through an SSL-inspection proxy with a non-compliant root CA.
  • All Python HTTP libraries are affected (urllib, requests, httpx, aiohttp, …) because they all use ssl.create_default_context() under the hood.
  • Setting SSL_CERT_FILE or SSL_CERT_DIR does not help — the certificate is found and loaded, but then rejected during verification.

Root cause

ssl.create_default_context() in Python 3.13+ sets verify_flags to include ssl.VERIFY_X509_STRICT (value 0x20). This flag tells OpenSSL to enforce the full set of RFC 5280 rules, including the requirement that CA certificates carry the Key Usage extension.

You can verify this yourself:

>>> import ssl
>>> ctx = ssl.create_default_context()
>>> bool(ctx.verify_flags & ssl.VERIFY_X509_STRICT)
True   # <-- Python 3.13+

On Python 3.12 the same check returns False.

Reproducer

The script below creates two test CAs — one with and one without the Key Usage extension — signs a localhost server certificate with each, and proves that Python 3.13 rejects the non-compliant one.

Save this as test_keyusage.py and run it with python3 test_keyusage.py:

#!/usr/bin/env python3
"""
Reproducer for Python 3.13+ rejecting CA certs without Key Usage extension.

Creates two self-signed CAs:
  - bad_ca:  has basicConstraints but NO keyUsage  (mimics broken proxy CA)
  - good_ca: has basicConstraints AND keyUsage      (compliant CA)

Signs a localhost server cert with each, then attempts a TLS handshake using
ssl.create_default_context() — the same code path used by urllib, requests, etc.

Expected output on Python 3.13+:
  CA WITHOUT keyUsage : REJECTED - CA cert does not include key usage extension
  CA WITH    keyUsage : OK

Expected output on Python 3.12 and below:
  CA WITHOUT keyUsage : OK
  CA WITH    keyUsage : OK
"""

import os
import socket
import ssl
import subprocess
import sys
import tempfile
import threading

def openssl(*args, stdin=None):
    r = subprocess.run(
        ["openssl", *args],
        input=stdin,
        capture_output=True,
    )
    if r.returncode != 0:
        raise RuntimeError(r.stderr.decode())

def make_ca(dir, name, include_key_usage):
    key = os.path.join(dir, f"{name}.key")
    cert = os.path.join(dir, f"{name}.pem")
    exts = ["-addext", "basicConstraints=critical,CA:TRUE"]
    if include_key_usage:
        exts += ["-addext", "keyUsage=critical,keyCertSign,cRLSign"]
    openssl(
        "req", "-x509", "-newkey", "rsa:2048",
        "-keyout", key, "-out", cert,
        "-days", "1", "-nodes",
        "-subj", f"/CN=Test {name}",
        *exts,
    )
    return key, cert

def make_server_cert(dir, ca_key, ca_cert, name):
    key = os.path.join(dir, f"{name}.key")
    csr = os.path.join(dir, f"{name}.csr")
    cert = os.path.join(dir, f"{name}.pem")
    openssl(
        "req", "-newkey", "rsa:2048",
        "-keyout", key, "-out", csr, "-nodes",
        "-subj", "/CN=localhost",
    )
    openssl(
        "x509", "-req", "-in", csr,
        "-CA", ca_cert, "-CAkey", ca_key,
        "-CAcreateserial", "-out", cert, "-days", "1",
        "-extfile", "/dev/stdin",
        stdin=b"subjectAltName=DNS:localhost",
    )
    return key, cert

def test_handshake(ca_cert, server_cert, server_key):
    """Return (success: bool, detail: str)."""
    server_ctx = ssl.SSLContext(ssl.PROTOCOL_TLS_SERVER)
    server_ctx.load_cert_chain(server_cert, server_key)

    client_ctx = ssl.create_default_context(cafile=ca_cert)

    srv = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
    srv.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
    srv.bind(("127.0.0.1", 0))
    port = srv.getsockname()[1]
    srv.listen(1)

    def accept():
        try:
            conn, _ = srv.accept()
            server_ctx.wrap_socket(conn, server_side=True).close()
        except Exception:
            pass
        finally:
            srv.close()

    t = threading.Thread(target=accept)
    t.start()

    try:
        with socket.create_connection(("127.0.0.1", port)) as sock:
            with client_ctx.wrap_socket(sock, server_hostname="localhost"):
                pass
        return True, "OK"
    except ssl.SSLCertVerificationError as exc:
        return False, exc.verify_message
    except Exception as exc:
        return False, f"{type(exc).__name__}: {exc}"
    finally:
        t.join(timeout=5)

def main():
    print(f"Python  {sys.version}")
    print(f"OpenSSL {ssl.OPENSSL_VERSION}")
    strict = bool(
        ssl.create_default_context().verify_flags & ssl.VERIFY_X509_STRICT
    )
    print(f"VERIFY_X509_STRICT enabled by default: {strict}")
    print()

    with tempfile.TemporaryDirectory() as d:
        bad_ca_key, bad_ca_cert = make_ca(d, "bad_ca", include_key_usage=False)
        good_ca_key, good_ca_cert = make_ca(d, "good_ca", include_key_usage=True)

        bad_srv_key, bad_srv_cert = make_server_cert(
            d, bad_ca_key, bad_ca_cert, "bad_srv",
        )
        good_srv_key, good_srv_cert = make_server_cert(
            d, good_ca_key, good_ca_cert, "good_srv",
        )

        ok, detail = test_handshake(bad_ca_cert, bad_srv_cert, bad_srv_key)
        status = "OK" if ok else f"REJECTED - {detail}"
        print(f"  CA WITHOUT keyUsage : {status}")

        ok, detail = test_handshake(good_ca_cert, good_srv_cert, good_srv_key)
        status = "OK" if ok else f"REJECTED - {detail}"
        print(f"  CA WITH    keyUsage : {status}")

    print()
    if strict:
        print("RESULT: This Python enforces strict X.509 — a CA cert without")
        print("        keyUsage WILL be rejected. Apply the workaround below.")
    else:
        print("RESULT: This Python does NOT enforce strict X.509 — you are")
        print("        not affected.")

if __name__ == "__main__":
    main()

Expected output on an affected system

Python  3.13.7 (main, ...) [GCC ...]
OpenSSL OpenSSL 3.5.3 16 Sep 2025
VERIFY_X509_STRICT enabled by default: True

  CA WITHOUT keyUsage : REJECTED - CA cert does not include key usage extension
  CA WITH    keyUsage : OK

RESULT: This Python enforces strict X.509 — a CA cert without
        keyUsage WILL be rejected. Apply the workaround below.

Workaround

The fix is a single file that disables the strict flag process-wide. It is picked up automatically by every Python invocation — no code changes needed in any application, library, or virtualenv.

Step 1 — Find your site-packages directory

python3 -c "import site; print(site.ENABLE_USER_SITE and site.getusersitepackages())"

If that prints False (user site disabled, e.g. inside a virtualenv), use the system/venv site-packages instead:

python3 -c "import sysconfig; print(sysconfig.get_path('purelib'))"

Step 2 — Create usercustomize.py (or sitecustomize.py)

Place the following file in the site-packages directory from Step 1. Use usercustomize.py for a per-user fix, or sitecustomize.py for a system-wide / virtualenv-wide fix.

"""
Workaround for Python 3.13+ rejecting CA certificates that lack the
Key Usage X.509v3 extension (RFC 5280 strict enforcement).

This affects environments behind SSL-inspection proxies (Netskope, Zscaler,
etc.) whose root CA was issued without keyUsage=keyCertSign,cRLSign.

How it works: monkey-patches ssl.create_default_context() to clear the
VERIFY_X509_STRICT flag, restoring Python 3.12 behavior.

Remove this file once your proxy's root CA has been re-issued with the
correct extensions.
"""
import ssl as _ssl

if hasattr(_ssl, "VERIFY_X509_STRICT"):
    _orig_create_default_context = _ssl.create_default_context

    def _create_default_context(*args, **kwargs):
        ctx = _orig_create_default_context(*args, **kwargs)
        ctx.verify_flags &= ~_ssl.VERIFY_X509_STRICT
        return ctx

    _ssl.create_default_context = _create_default_context

Step 3 — Verify

python3 -c "
import ssl
ctx = ssl.create_default_context()
strict = bool(ctx.verify_flags & ssl.VERIFY_X509_STRICT)
print('VERIFY_X509_STRICT:', strict)
assert not strict, 'Workaround not active'
print('Workaround is active.')
"

One-liner for the impatient

# Determine site-packages and write the workaround in one shot:
python3 -c "
import site, sysconfig, pathlib
d = site.getusersitepackages() if site.ENABLE_USER_SITE else sysconfig.get_path('purelib')
p = pathlib.Path(d) / 'usercustomize.py'
p.parent.mkdir(parents=True, exist_ok=True)
p.write_text('''import ssl as _ssl
if hasattr(_ssl, \"VERIFY_X509_STRICT\"):
    _orig = _ssl.create_default_context
    def _patched(*a, **kw):
        ctx = _orig(*a, **kw)
        ctx.verify_flags &= ~_ssl.VERIFY_X509_STRICT
        return ctx
    _ssl.create_default_context = _patched
''')
print(f'Written to {p}')
"

Security note

This workaround does not disable certificate verification. The TLS handshake still validates the full certificate chain, hostname, and expiry. The only change is that CA certificates are no longer required to carry the Key Usage extension — the same behavior Python 3.12 and below had.

Long-term fix

The proper fix is for the proxy vendor (Netskope) to re-issue their root CA certificate with the required extension:

X509v3 Key Usage: critical
    Certificate Sign, CRL Sign

Once that is done, the usercustomize.py workaround should be removed.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment