Testing API TLS Compatibility with Proxy-Based Cipher Suite Simulation
October 6, 2026
Why TLS Version and Cipher Suite Matter
Modern APIs expose TLS endpoints that must be consumable by a wide range of clients—from legacy Java 8 services to the latest browsers. Server administrators often disable old protocols (TLS 1.0/1.1) or weak ciphers (RC4, 3DES) for security, while some internal systems still require them for compatibility. When you integrate a third‑party API, you need to verify that your client library or custom code works with the exact TLS configuration the server expects.
Testing this directly against the production endpoint can be risky: you might trigger rate limits, cause alerting, or inadvertently expose sensitive data. A safer approach is to place a controllable TLS‑terminating proxy between your client and the API. The proxy handles the client‑side TLS handshake, letting you dictate which protocol version and cipher suite are offered, then re‑encrypts the traffic to the upstream server using its own (usually permissive) settings.
This guide shows how to set up such a proxy with mitmproxy, enforce TLS versions and cipher suites, and verify API responses using Python. The same principles apply to other tools (Squid with SSL‑bump, HAProxy, or custom Go/TLS servers).
Prerequisites
- A Linux/macOS workstation with Docker (optional) or root access to install mitmproxy.
- Python 3.9+ and the
requestslibrary. - An API endpoint you control or a test service (e.g., https://httpbin.org/tls‑version).
Installing mitmproxy
# Using pip (recommended for latest version)
pip3 install mitmproxy
# Verify
mitmproxy --version
If you prefer Docker:
docker run -it --rm -p 8080:8080 mitmproxy/mitmproxy
The proxy will listen on localhost:8080 for HTTP and HTTPS traffic.
Understanding the TLS Handshake Flow
When a client connects through an HTTP CONNECT proxy to an HTTPS site, the proxy merely forwards the raw TLS ClientHello. With mitmproxy in transparent mode, we can terminate the TLS connection, inspect (or modify) the ClientHello, and then initiate a new TLS connection to the upstream server. This gives us full control over the version and cipher suite presented by the client.
Mitmproxy achieves this via an addon that replaces the default SSLContext used for the client‑side connection.
Step‑by‑Step: Forcing TLS 1.2 with a Specific Cipher Suite
We’ll create a simple addon that forces the client side to use TLS 1.2 and only the cipher TLS_ECDHE_RSA_WITH_AES_128_GCM_SHA256 (IANA name TLS_ECDHE_RSA_WITH_AES_128_GCM_SHA256).
- Create the addon file
tls_force.py:
from mitmproxy import tls, ctx
class TLSCipherForcer:
def __init__(self, tls_version: tls.TLSVersion, cipher_name: str):
self.tls_version = tls_version
self.cipher_name = cipher_name
def clientconnect(self, data: tls.ClientHelloData):
# Called after the ClientHello is parsed but before we send our own ClientHello
ctx.log.info(f"Forcing TLS version {self.tls_version} and cipher {self.cipher_name}")
# Build a new ClientHello with our constraints
data.version = self.tls_version
# Keep only the desired cipher; remove all others
data.ciphers = [self.cipher_name]
# Optionally disable extensions that could influence version negotiation
data.extensions = []
addons = [
TLSCipherForcer(tls.TLSVersion.TLSv1_2, "TLS_ECDHE_RSA_WITH_AES_128_GCM_SHA256")
]
- Run mitmproxy with the addon:
mitmproxy -s tls_force.py --mode regular --listen-port 8080
The --mode regular flag tells mitmproxy to act as a standard forward proxy (not transparent). Your client will need to point to http://localhost:8080 as its proxy.
- Configure your Python client to use the proxy
import requests
proxies = {
"http": "http://localhost:8080",
"https": "http://localhost:8080",
}
response = requests.get(
"https://httpbin.org/tls-version",
proxies=proxies,
timeout=10
)
print(response.status_code)
print(response.json())
httpbin.org/tls-version returns the TLS version and cipher suite negotiated by the server, letting you verify that the server saw the forced client settings.
Expected Output
If the server supports TLS 1.2 and the chosen cipher, you’ll see something like:
{
"tls_version": "TLS 1.2",
"cipher": "TLS_ECDHE_RSA_WITH_AES_128_GCM_SHA256"
}
If the server does not support that combination, the connection will fail with an SSL error (SSL: WRONG_VERSION_NUMBER or handshake failure).
Testing Multiple Configurations Automatically
Manually changing the addon for each combination is tedious. Instead, we can drive mitmproxy from a Python script that updates the addon’s parameters on the fly. Mitmproxy’s --set option allows runtime changes, but a simpler approach is to create a temporary addon file for each test.
Here’s a helper that generates a new addon, runs mitmproxy in a subprocess, executes a request, and collects the result:
import subprocess, time, json, os, signal
from pathlib import Path
def test_tls_config(tls_version_str: str, cipher_name: str, target_url: str) -> dict:
# Map string to mitmproxy TLSVersion enum
version_map = {
"TLSv1.0": "TLSv1_0",
"TLSv1.1": "TLSv1_1",
"TLSv1.2": "TLSv1_2",
"TLSv1.3": "TLSv1_3",
}
tls_enum = version_map[tls_version_str]
addon_code = f"""
from mitmproxy import tls, ctx
class TLSCipherForcer:
def clientconnect(self, data: tls.ClientHelloData):
data.version = tls.{tls_enum}
data.ciphers = ["{cipher_name}"]
data.extensions = []
addons = [TLSCipherForcer()]
"""
addon_path = Path("tmp_tls_addon.py")
addon_path.write_text(addon_code)
# Start mitmproxy
proc = subprocess.Popen([
"mitmproxy",
f"-s", str(addon_path),
"--mode", "regular",
"--listen-port", "8080",
"--quiet"
], stdout=subprocess.PIPE, stderr=subprocess.PIPE)
# Give the proxy a moment to start
time.sleep(2)
try:
resp = requests.get(target_url, proxies={"http": "http://localhost:8080", "https": "http://localhost:8080"}, timeout=10)
result = {
"tls_version": tls_version_str,
"cipher": cipher_name,
"status_code": resp.status_code,
"json": resp.json() if resp.headers.get("content-type", "").startswith("application/json") else None,
"text": resp.text[:200]
}
except Exception as e:
result = {
"tls_version": tls_version_str,
"cipher": cipher_name,
"error": str(e)
}
finally:
proc.send_signal(signal.SIGINT)
proc.wait(timeout=5)
addon_path.unlink(missing_ok=True)
return result
# Example matrix
matrix = [
("TLSv1.0", "TLS_ECDHE_RSA_WITH_AES_128_CBC_SHA"),
("TLSv1.1", "TLS_ECDHE_RSA_WITH_AES_256_CBC_SHA8"),
("TLSv1.2", "TLS_ECDHE_RSA_WITH_AES_128_GCM_SHA256"),
("TLSv1.3", "TLS_AES_128_GCM_SHA256"),
]
for ver, cip in matrix:
out = test_tls_config(ver, cip, "https://httpbin.org/tls-version")
print(json.dumps(out, indent=2))
Running the script yields a table of successes/failures, giving you a quick compatibility matrix for the API.
Real‑World Example: Validating a Payment Gateway
Suppose you integrate with a payment processor that claims to support only TLS 1.2 with forward‑secrecy ciphers. Using the approach above, you can:
- Confirm that the gateway rejects TLS 1.0/1.1 connections (helps you satisfy PCI‑DSS requirements).
- Detect if the gateway unexpectedly allows a weak cipher like
TLS_RSA_WITH_3DES_EDE_CBC_SHA(a potential downgrade vector). - Document the exact TLS configuration your integration relies on, which is useful for auditors and for future migration planning.
Sample Output (Success)
{
"tls_version": "TLSv1.2",
"cipher": "TLS_ECDHE_RSA_WITH_AES_128_GCM_SHA256",
"status_code": 200,
"json": {
"tls_version": "TLS 1.2",
"cipher": "TLS_ECDHE_RSA_WITH_AES_128_GCM_SHA256"
}
}
Sample Output (Failure)
{
"tls_version": "TLSv1.0",
"cipher": "TLS_ECDHE_RSA_WITH_AES_128_CBC_SHA",
"error": "HTTPSConnectionPool(host='httpbin.org', port=443): Max retries exceeded with url: /tls-version: [SSL: TLSV1_ALERT_PROTOCOL_VERSION] tlsv1 alert protocol version (_ssl.c:1129)"
}
These results make it trivial to produce a compliance report.
Extending the Technique
- Client Certificate Testing: Replace the
clientconnecthook to load a specific client cert and test mutual TLS. - SNI Manipulation: Modify the
servernameextension to verify virtual‑host handling. - Protocol Downgrade Detection: Run a sweep from TLS 1.3 down to TLS 1.0 and plot the first version that succeeds.
- Integration with CI: Package the script as a Docker image and run it in your pipeline to gate releases on TLS compatibility.
Troubleshooting Tips
| Symptom | Likely Cause | Fix |
|---|---|---|
| Connection timeout | Proxy not listening or firewall blocking | Verify mitmproxy is running and ports are open (netstat -tlnp). |
SSL: CERTIFICATE_VERIFY_FAILED |
Proxy uses its own CA; client doesn’t trust it | Add mitmproxy’s CA to your trust store or set verify=False (for testing only). |
| Server returns 400 Bad Request | Host header altered by proxy | Ensure you’re using a regular forward proxy (--mode regular) not transparent mode. |
| No change in observed TLS version | Addon not applied (syntax error) | Check mitmproxy logs for startup errors; run with --verbose. |
Conclusion
Using a TLS‑terminating proxy gives you precise, repeatable control over the client‑side TLS handshake without modifying your application code. By forcing specific versions and cipher suites, you can:
- Validate that external APIs meet your security baseline.
- Discover unintended weak‑protocol support that could be abused.
- Produce concrete evidence for audits, penetration tests, or vendor discussions.
The combination of mitmproxy’s extensible addon system and a simple Python driver lets you automate large compatibility matrices and embed the checks into your CI/CD pipeline. Start with the tiny example above, expand the matrix to cover all the ciphers your organization cares about, and make TLS compatibility testing a routine part of your API integration workflow.
Ready to try it out? Spin up a mitmproxy instance, drop the addon file, and run a single request—you’ll instantly see which TLS settings your API truly accepts.