5.2 TLS Secure Client and Fault Diagnosis
ssl.SSLCertVerificationError: certificate verify failedRetrying won’t fix it automatically. Disabling verification isn’t a solution. You must first determine where the failure occurs in the stack, then investigate the evidence at that layer.
1. Draw the Boundary of the Failure
name resolution → TCP connect → TLS handshake → HTTP exchange| Symptom | First Check | Don't Guess First |
|---|---|---|
Name or service not known | DNS name and resolver | certificate |
Connection refused | address, port, listener | cipher suite |
| TCP timeout | route, firewall, service load | HTTP status |
CERTIFICATE_VERIFY_FAILED | trust path, name, time, usage | REST route |
| TLS succeeds but receives 421/404 | SNI, Host, virtual host, and HTTP routing | CA store |
Use the table to choose the next evidence to inspect. An HTTP routing error does not justify changing certificate verification.
2. Inspect the Handshake with OpenSSL
When checking a virtual host, -servername is not just decoration, it actually sends the SNI extension:
openssl s_client \
-connect example.com:443 \
-servername example.com \
-verify_hostname example.com \
-alpn http/1.1 \
-showcerts \
-verify_return_error </dev/nullLook for these pieces of evidence in the output:
- The negotiated TLS version and cipher suite;
- The leaf certificate’s Subject and Subject Alternative Name;
- Which intermediate certificates the server actually sends;
Verify return code;- Whether ALPN selected the expected protocol.
-showcerts shows the server’s certificate list, but this does not mean OpenSSL has verified the trustworthiness of every certificate in that list. The actual verification result must be examined. Hostname verification must be explicitly enabled depending on the OpenSSL command and version you're using, don’t assume it’s active just because the handshake succeeded.
The command above already enables name verification. To separate DNS from TLS identity, connect to an authorized test IP while retaining -servername and -verify_hostname. Replace the documentation address 192.0.2.10 below before running:
openssl s_client \
-connect 192.0.2.10:443 \
-servername example.com \
-verify_hostname example.com \
-alpn http/1.1 \
-verify_return_error </dev/null3. A Python Client with Default Identity Verification
The example targets Python 3.12 with a compatible OpenSSL. ssl.create_default_context() loads default trust, or the specified cafile when supplied. The client requires TLS 1.2+, disables legacy CN fallback, and bounds TLS exchange time and response size.
#!/usr/bin/env python3
import argparse
import ipaddress
import re
import socket
import ssl
import sys
import time
MAX_RESPONSE = 2 * 1024 * 1024
TARGET = re.compile(r"/(?:[A-Za-z0-9._~!$&'()*+,;=:@/?-]|%[0-9A-Fa-f]{2})*")
def https_get(host, path="/", port=443, *, cafile=None, connect_host=None):
if not TARGET.fullmatch(path):
raise ValueError("path must be an ASCII origin-form URI without fragment")
if not 1 <= port <= 65535:
raise ValueError("port must be between 1 and 65535")
try:
address = ipaddress.ip_address(host)
identity = str(address)
authority = f"[{identity}]" if address.version == 6 else identity
except ValueError:
# Accept DNS A-label names; convert international names before calling.
if not re.fullmatch(r"[A-Za-z0-9](?:[A-Za-z0-9.-]*[A-Za-z0-9])?", host):
raise ValueError("host must be an IP literal or ASCII DNS name")
identity = host
authority = host
if port != 443:
authority += f":{port}"
context = ssl.create_default_context(cafile=cafile)
context.minimum_version = ssl.TLSVersion.TLSv1_2
context.hostname_checks_common_name = False
context.set_alpn_protocols(["http/1.1"])
with socket.create_connection((connect_host or identity, port), timeout=5) as raw:
deadline = time.monotonic() + 10
def remaining():
seconds = deadline - time.monotonic()
if seconds <= 0:
raise TimeoutError("TLS exchange deadline exceeded")
return seconds
raw.settimeout(remaining())
with context.wrap_socket(
raw, server_hostname=identity, suppress_ragged_eofs=False,
) as tls:
selected = tls.selected_alpn_protocol()
if selected not in (None, "http/1.1"):
raise ssl.SSLError("unexpected ALPN protocol")
print("TLS:", tls.version())
print("cipher:", tls.cipher())
print("ALPN:", selected)
request = (
f"GET {path} HTTP/1.1\r\n"
f"Host: {authority}\r\n"
"User-Agent: atlas-tls-client/1\r\n"
"Accept: */*\r\n"
"Connection: close\r\n"
"\r\n"
).encode("ascii")
tls.settimeout(remaining())
tls.sendall(request)
data = bytearray()
while True:
tls.settimeout(remaining())
chunk = tls.recv(min(65536, MAX_RESPONSE + 1 - len(data)))
if not chunk:
tls.settimeout(remaining())
tls.unwrap().close()
return bytes(data)
data.extend(chunk)
if len(data) > MAX_RESPONSE:
raise ValueError("raw HTTP response exceeds 2 MiB")
def main():
parser = argparse.ArgumentParser()
parser.add_argument("host", nargs="?", default="example.com")
parser.add_argument("--port", type=int, default=443)
parser.add_argument("--cafile")
parser.add_argument("--connect-host")
args = parser.parse_args()
try:
response = https_get(
args.host, port=args.port, cafile=args.cafile,
connect_host=args.connect_host,
)
except ssl.SSLCertVerificationError as exc:
print(f"certificate verification failed: {exc.verify_message}", file=sys.stderr)
raise SystemExit(2) from exc
except (ssl.SSLError, OSError, ValueError) as exc:
print(f"exchange failed: {exc}", file=sys.stderr)
raise SystemExit(1) from exc
head, marker, _body = response.partition(b"\r\n\r\n")
if not marker:
raise SystemExit("incomplete HTTP header section")
print(head.decode("iso-8859-1"))
if __name__ == "__main__":
main()Save as tls_client.py and run:
python3 tls_client.py example.comFor DNS names, server_hostname supports SNI and name verification. IP literals are checked against IP SANs and normally do not send DNS SNI. connect_host changes only the network destination; the expected identity and HTTP Host still come from host, with a nondefault port included in Host. Supply ASCII DNS A-labels and percent-encode non-ASCII URI content beforehand.
The client retains at most 2 MiB of raw HTTP response and gives the TLS handshake, request write, and read-through-close a shared 10-second budget after TCP connects. DNS and candidate connection attempts are outside that budget. Connection: close requests closure; it does not override Content-Length or chunked framing. This program does not parse those rules or handle redirects, proxies, compression, or reuse, so clean TLS closure alone cannot prove a complete HTTP body.
suppress_ragged_eofs=False reports TCP EOF without close_notify as an error. Loading a trust store also does not automatically perform every online revocation check; Python ssl’s CRL/OCSP capabilities and configuration need separate attention. Use a maintained HTTP library for complete message handling while preserving identity checks and resource limits.
4. Don't Use These "Patches"
# Do not put in production client
context.check_hostname = False
context.verify_mode = ssl.CERT_NONEThis simultaneously removes both the trust path and service identity protection. Traffic may still appear encrypted, but the client has no way of knowing whether the key was negotiated with the real server or an attacker.
A more common correct fix is:
- Complete the server's intermediate certificate chain;
- Use a certificate that correctly covers the intended DNS name;
- Correct the system clock;
- Configure a private CA as a clear trust anchor for specific clients;
- Fix the enterprise trust configuration in proxies or TLS interception systems;
- Record and complete certificate renewal, rather than temporarily ignoring expiry.
5. Private CA and mTLS
A private service can configure its CA specifically for this client:
context = ssl.create_default_context(cafile="company-root-ca.pem")This is entirely different from CERT_NONE: the former modifies the trust anchor set while still performing certificate and hostname verification; the latter disables all verification.
An explicit cafile is not automatically added to default system roots. If policy requires both, create the default context first and then call load_verify_locations(cafile=...). A local experiment does not need changes to the machine’s global trust store.
mTLS also requires the client to present a certificate:
context = ssl.create_default_context(cafile="company-root-ca.pem")
context.load_cert_chain("client-cert.pem", "client-key.pem")Loading a client certificate does not make the server authenticate it. The server must request the certificate and validate its trust path. A Python server can require it with CERT_REQUIRED and an explicit client-CA configuration.
mTLS can authenticate a client's certificate identity, but it does not automatically grant application authorization. The server must still map the certificate identity to a specific principal and then determine whether that principal is authorized to perform the current operation. The private key must also have access control policies and rotation policies in place.
6. From Error to Root Cause
Certificate expired
Verify the current time, the leaf certificate's validity period, and the renewal job schedule. When replacing a leaf certificate, also confirm that the server is actually reloading the full chain file it references.
Unable to get local issuer certificate
First check whether the expected root is trusted, then compare the server’s list with the required intermediates. A missing trust anchor and a missing intermediate can produce this same error. Do not assume every client fetches missing certificates automatically.
Hostname mismatch
Check the actual URL hostname, the SNI value, and the leaf certificate's subjectAltName. Simply replacing a URL with an IP address often simultaneously breaks virtual hosting and identity verification.
Protocol version / no shared cipher
First, verify the supported protocol versions and security policies on both client and server. Then, examine the intersection of cipher suites, key exchange groups, and signature algorithms. Don't blindly re-enable TLS 1.0/1.1 or weak algorithms across the entire system to support a single old client. Instead, prioritize upgrading or isolating that client, and evaluate the compatibility boundary based on current TLS best practices.
7. Exercises and Verification
- Use
openssl s_clientto observe a host you're authorized to test, and record the TLS version, cipher, ALPN, and verification result. - On your local machine, change the host name in the Python client to one that doesn't match the certificate, and observe
verify_message; do not disable verification. - Explain in one sentence what SNI, hostname verification, and HTTP
Hostdo. - Design a private CA configuration for an internal service, specifying where the trust anchor is stored, who is authorized to update it, and how certificate rotation works.
- Explain why successful mTLS authentication does not automatically grant authorization.
Verification means identifying the failed DNS, TCP, TLS, or HTTP step from evidence while keeping certificate verification enabled.
Only after the certificate chain reaches a trusted anchor and the service identity matches the target name should the client continue over the protected channel. For persistent bidirectional messaging, continue with WebSocket and gRPC.