Windows SChannel TLS/mTLS provider for requests and websockets — smartcard and PKI authentication via native Windows APIs
Project description
requests-schannel
Windows SChannel TLS/mTLS provider for requests and websockets — smartcard and PKI authentication via native Windows APIs.
requests-schannel replaces OpenSSL with the built-in Windows SChannel SSPI provider, enabling:
- Smartcard / PIV authentication — use certificates on hardware tokens without exporting private keys
- Windows certificate store integration — select client certificates by thumbprint, subject, or let Windows auto-select
- Native trust store — server verification uses the Windows trust store (no CA bundle files)
- TLS 1.2 / 1.3 with ALPN negotiation
- Zero native dependencies — pure-Python ctypes backend included; optional sspilib backend for enhanced performance
Table of Contents
- Installation
- Quick Start
- Usage
- Client Certificate (mTLS)
- Backends
- API Reference
- Configuration
- Error Handling
- Testing
- License
Installation
pip install requests-schannel
With optional dependencies:
# requests integration
pip install requests-schannel[requests]
# websockets integration
pip install requests-schannel[websockets]
# sspilib backend (recommended for production)
pip install requests-schannel[sspilib]
# Everything
pip install requests-schannel[all]
Note: This library requires Windows and Python 3.12+.
Quick Start
import requests
from requests_schannel import SchannelAdapter
session = requests.Session()
session.mount("https://", SchannelAdapter())
resp = session.get("https://example.com")
print(resp.status_code)
Usage
requests Integration
Drop-in Adapter
Mount SchannelAdapter onto a requests.Session to route all HTTPS traffic through SChannel:
import requests
from requests_schannel import SchannelAdapter
session = requests.Session()
session.mount("https://", SchannelAdapter())
resp = session.get("https://www.howsmyssl.com/a/check")
print(resp.json()["tls_version"])
Convenience Session Factory
create_session() returns a pre-configured session with the adapter already mounted:
from requests_schannel import create_session
session = create_session()
resp = session.get("https://example.com")
WebSocket Integration
Connect to WebSocket servers over TLS using SChannel:
import asyncio
from requests_schannel.ws import schannel_connect
async def main():
async with schannel_connect("wss://echo.websocket.org") as ws:
await ws.send("hello")
response = await ws.recv()
print(response)
asyncio.run(main())
Low-Level Socket API
Use SchannelContext and SchannelSocket directly for custom TLS connections:
import socket
from requests_schannel import SchannelContext
ctx = SchannelContext()
ctx.set_alpn_protocols(["http/1.1"])
raw_sock = socket.create_connection(("example.com", 443))
tls_sock = ctx.wrap_socket(raw_sock, server_hostname="example.com")
tls_sock.send(b"GET / HTTP/1.1\r\nHost: example.com\r\n\r\n")
print(tls_sock.recv(4096))
tls_sock.close()
Async Socket API
For asyncio applications that need TLS without requests or websockets:
import asyncio
from requests_schannel import SchannelContext, AsyncSchannelSocket
async def main():
ctx = SchannelContext()
sock = await AsyncSchannelSocket.connect("example.com", 443, ctx)
await sock.send(b"GET / HTTP/1.1\r\nHost: example.com\r\n\r\n")
data = await sock.recv(4096)
print(data)
await sock.close()
asyncio.run(main())
Client Certificate (mTLS)
Select a client certificate from the Windows certificate store for mutual TLS authentication.
By Thumbprint
from requests_schannel import SchannelAdapter, create_session
# Using the adapter directly
adapter = SchannelAdapter(client_cert_thumbprint="AB12CD34EF56...")
session = requests.Session()
session.mount("https://", adapter)
# Or using the convenience factory
session = create_session(client_cert_thumbprint="AB12CD34EF56...")
By Subject Name
session = create_session(client_cert_subject="CN=myuser")
Auto-Select
Let Windows choose a suitable client certificate (may display a UI prompt):
session = create_session(auto_select_client_cert=True)
Custom Certificate Store
By default, certificates are loaded from the "MY" (Personal) store. Specify a different store:
session = create_session(
client_cert_thumbprint="AB12CD34EF56...",
cert_store_name="MY", # "MY", "Root", "CA", etc.
)
WebSocket mTLS
async with schannel_connect(
"wss://secure.example.com/ws",
client_cert_thumbprint="AB12CD34EF56...",
) as ws:
await ws.send("authenticated!")
Backends
requests-schannel provides two backends for interacting with the Windows SChannel API:
| Backend | Description | Install |
|---|---|---|
| ctypes | Pure-Python ctypes calls to secur32.dll / crypt32.dll. Zero dependencies. Ships with the package. |
Built-in |
| sspilib | Uses the sspilib package for SSPI operations. Recommended for production. | pip install requests-schannel[sspilib] |
The backend is auto-selected (sspilib if available, otherwise ctypes). To force a specific backend:
from requests_schannel import SchannelAdapter, SchannelContext
# Via adapter
adapter = SchannelAdapter(backend="ctypes")
# Via context
ctx = SchannelContext(backend="sspilib")
API Reference
SchannelAdapter
requests.adapters.HTTPAdapter subclass for SChannel TLS.
SchannelAdapter(
client_cert_thumbprint: str | None = None,
client_cert_subject: str | None = None,
auto_select_client_cert: bool = False,
cert_store_name: str = "MY",
alpn_protocols: list[str] | None = None,
backend: str | None = None,
schannel_context: SchannelContext | None = None,
**kwargs, # passed to HTTPAdapter
)
Properties:
schannel_context— the underlyingSchannelContextinstance
create_session()
Convenience factory that returns a requests.Session with SchannelAdapter mounted on https://.
create_session(
client_cert_thumbprint: str | None = None,
client_cert_subject: str | None = None,
auto_select_client_cert: bool = False,
cert_store_name: str = "MY",
alpn_protocols: list[str] | None = None,
backend: str | None = None,
**kwargs,
) -> requests.Session
SchannelContext
ssl.SSLContext-compatible object backed by Windows SChannel. Thread-safe — the credential handle is shared; each wrap_socket() creates a new per-connection security context.
SchannelContext(backend: str | SchannelBackend | None = None)
Properties:
client_cert_thumbprint— SHA-1 thumbprint for mTLS client cert selectionclient_cert_subject— subject name substring for client cert selectionauto_select_client_cert— let Windows auto-select a client certcert_store_name— Windows certificate store name (default"MY")minimum_version/maximum_version—TlsVersion.TLSv1_2orTlsVersion.TLSv1_3verify_mode—ssl.CERT_REQUIRED(default) orssl.CERT_NONEcheck_hostname— enable/disable hostname verification
Methods:
set_alpn_protocols(protocols: list[str])— set ALPN protocol list (e.g.["h2", "http/1.1"])wrap_socket(sock, server_hostname=..., do_handshake_on_connect=True) -> SchannelSocketload_cert_chain(),load_verify_locations(),set_ciphers()— no-ops forssl.SSLContextcompatibility
SchannelSocket
TLS-wrapped socket with an ssl.SSLSocket-compatible interface.
Methods:
do_handshake()— perform the TLS handshakesend(data: bytes) -> int/write(data: bytes) -> intrecv(bufsize: int) -> bytes/read(nbytes: int) -> bytesrecv_into(buffer, nbytes) -> intgetpeercert(binary_form=False)— get peer certificateselected_alpn_protocol() -> str | Noneversion() -> str | None— negotiated TLS versionclose()— send close_notify and close the connectionmakefile(mode, buffering)— create a file-like wrapper (for urllib3 compatibility)
AsyncSchannelSocket
Async wrapper around SchannelSocket for asyncio.
# Connect and handshake
sock = await AsyncSchannelSocket.connect(host, port, context)
# Wrap existing socket
sock = await AsyncSchannelSocket.wrap(raw_sock, context, server_hostname)
# I/O
await sock.send(data)
data = await sock.recv(4096)
await sock.close()
schannel_connect()
Async context manager for WebSocket connections over SChannel TLS.
async with schannel_connect(
uri: str,
*,
context: SchannelContext | None = None,
client_cert_thumbprint: str | None = None,
client_cert_subject: str | None = None,
auto_select_client_cert: bool = False,
cert_store_name: str = "MY",
backend: str | None = None,
timeout: float | None = 30.0,
additional_headers: dict[str, str] | None = None,
**ws_kwargs,
) as ws:
...
Data Classes
TlsVersion— enum:TLSv1_2,TLSv1_3CertInfo— certificate metadata:thumbprint,subject,issuer,friendly_name,not_before,not_after,has_private_key,serial_number,der_encodedConnectionInfo— TLS connection details:protocol_version,cipher_algorithm,cipher_strength,hash_algorithm,hash_strength,exchange_algorithm,exchange_strengthStreamSizes— SChannel buffer sizes:header,trailer,max_message,buffers,block_size
Configuration
TLS Version
from requests_schannel import SchannelContext, TlsVersion
ctx = SchannelContext()
ctx.minimum_version = TlsVersion.TLSv1_2 # default
ctx.maximum_version = TlsVersion.TLSv1_3 # default
ALPN Protocols
ctx = SchannelContext()
ctx.set_alpn_protocols(["h2", "http/1.1"])
Disable Server Verification
Warning: Only use this for testing/development.
import ssl
ctx = SchannelContext()
ctx.verify_mode = ssl.CERT_NONE
ctx.check_hostname = False
Error Handling
All exceptions inherit from SchannelError:
| Exception | Description |
|---|---|
SchannelError |
Base exception for all SChannel errors |
HandshakeError |
TLS handshake failed |
CertificateError |
Base for certificate-related errors |
CertificateNotFoundError |
Certificate not found in the Windows store |
CertificateExpiredError |
Certificate has expired |
CertificateUntrustedError |
Certificate chain is not trusted |
CertificateVerificationError |
Server certificate verification failed |
CredentialError |
Failed to acquire SSPI credentials |
DecryptionError |
Failed to decrypt incoming TLS data |
EncryptionError |
Failed to encrypt outgoing TLS data |
BackendError |
Backend (sspilib or ctypes) is unavailable |
ContextExpiredError |
Security context has expired |
RenegotiationError |
TLS renegotiation failed |
from requests_schannel._errors import SchannelError, CertificateNotFoundError
try:
session = create_session(client_cert_thumbprint="INVALID...")
session.get("https://secure.example.com")
except CertificateNotFoundError:
print("Certificate not found in store")
except SchannelError as e:
print(f"SChannel error: {e}")
Testing
# Install dev dependencies
pip install -e ".[test]"
# Run unit tests
pytest tests/unit/
# Run integration tests (requires Windows with network access)
pytest tests/integration/
# Run all tests with coverage
pytest --cov=requests_schannel --cov-report=term-missing
License
Project details
Download files
Download the file for your platform. If you're not sure which to choose, learn more about installing packages.
Source Distribution
Built Distribution
Filter files by name, interpreter, ABI, and platform.
If you're not sure about the file name format, learn more about wheel file names.
Copy a direct link to the current filters
File details
Details for the file requests_schannel-0.1.1.tar.gz.
File metadata
- Download URL: requests_schannel-0.1.1.tar.gz
- Upload date:
- Size: 89.7 kB
- Tags: Source
- Uploaded using Trusted Publishing? Yes
- Uploaded via: twine/6.1.0 CPython/3.13.7
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
9c0d6717405c9f54bb5f6c82ddfb72de423527c368a4e6237d69002a048001c8
|
|
| MD5 |
7e53211c9b48fac9e84e66b1e97e163e
|
|
| BLAKE2b-256 |
9cb6413f3e063f74de4ad0b8df79ff841e2ba9f9c275ebb719685b099f4f018b
|
Provenance
The following attestation bundles were made for requests_schannel-0.1.1.tar.gz:
Publisher:
publish.yml on ruckc/requests-schannel
-
Statement:
-
Statement type:
https://in-toto.io/Statement/v1 -
Predicate type:
https://docs.pypi.org/attestations/publish/v1 -
Subject name:
requests_schannel-0.1.1.tar.gz -
Subject digest:
9c0d6717405c9f54bb5f6c82ddfb72de423527c368a4e6237d69002a048001c8 - Sigstore transparency entry: 1111837302
- Sigstore integration time:
-
Permalink:
ruckc/requests-schannel@e612f57f355cc8befafa5f718798ea23541090b5 -
Branch / Tag:
refs/heads/main - Owner: https://github.com/ruckc
-
Access:
private
-
Token Issuer:
https://token.actions.githubusercontent.com -
Runner Environment:
github-hosted -
Publication workflow:
publish.yml@e612f57f355cc8befafa5f718798ea23541090b5 -
Trigger Event:
workflow_run
-
Statement type:
File details
Details for the file requests_schannel-0.1.1-py3-none-any.whl.
File metadata
- Download URL: requests_schannel-0.1.1-py3-none-any.whl
- Upload date:
- Size: 32.1 kB
- Tags: Python 3
- Uploaded using Trusted Publishing? Yes
- Uploaded via: twine/6.1.0 CPython/3.13.7
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
20be2dab4b148df0d9d2f0c70d8f097384e34dc8d15bc375c23b4a59d86fca30
|
|
| MD5 |
a16592dc319ae75836b8ed3c9ce6414a
|
|
| BLAKE2b-256 |
b00fd9c1e7f242565ad10ec00670dae4927da930746ec4986ff3658afd4097cb
|
Provenance
The following attestation bundles were made for requests_schannel-0.1.1-py3-none-any.whl:
Publisher:
publish.yml on ruckc/requests-schannel
-
Statement:
-
Statement type:
https://in-toto.io/Statement/v1 -
Predicate type:
https://docs.pypi.org/attestations/publish/v1 -
Subject name:
requests_schannel-0.1.1-py3-none-any.whl -
Subject digest:
20be2dab4b148df0d9d2f0c70d8f097384e34dc8d15bc375c23b4a59d86fca30 - Sigstore transparency entry: 1111837340
- Sigstore integration time:
-
Permalink:
ruckc/requests-schannel@e612f57f355cc8befafa5f718798ea23541090b5 -
Branch / Tag:
refs/heads/main - Owner: https://github.com/ruckc
-
Access:
private
-
Token Issuer:
https://token.actions.githubusercontent.com -
Runner Environment:
github-hosted -
Publication workflow:
publish.yml@e612f57f355cc8befafa5f718798ea23541090b5 -
Trigger Event:
workflow_run
-
Statement type: