TLS hostname mismatch: "certificate is not valid for X"
curl reports (60) SSL: no alternative certificate subject name matches target hostname 'wild.example.com', Python reports certificate verify failed: Hostname mismatch, certificate is not valid for 'wild.example.com', and the certificate is perfectly valid. The chain verifies, the CA is trusted, the dates are in range, and the key is the right size — the only thing wrong is the name. The client compared the name it dialled against the certificate's subjectAltName list, found no entry, and aborted before any HTTP was sent.
That single fact resolves four different-looking error families: OpenSSL's error 62 at 0 depth lookup: hostname mismatch, Node's ERR_TLS_CERT_ALTNAME_INVALID, curl's exit code 60, and Python's SSLCertVerificationError. They are all the same check at the same place in the handshake. I rebuilt the whole matrix on a lab host — one CA, one leaf whose SAN covers lab.example.com and *.wild.example.com, one CN-only certificate, and four clients — so every string below is pasted from a run, not from memory. Host: Debian 13 on Linux 6.17 (aarch64), OpenSSL 3.5.6, curl 8.14.1, Python 3.13.5, Node v26.5.1.
TL;DR
- Hostname verification is a compare of the dialled name against the certificate's
subjectAltNameextension. It runs after chain validation, so a mismatch error always means the trust layer already passed. - Measured, same certificate, one name wrong, four clients:
- curl →
curl: (60) SSL: no alternative certificate subject name matches target hostname 'wild.example.com', exit 60 - OpenSSL CLI →
error 62 at 0 depth lookup: hostname mismatch, exit 2 (→error 64 … IP address mismatchfor an IP literal) - Python →
SSLCertVerificationError: [SSL: CERTIFICATE_VERIFY_FAILED] certificate verify failed: Hostname mismatch, certificate is not valid for 'wild.example.com'. (_ssl.c:1029) - Node →
ERR_TLS_CERT_ALTNAME_INVALID: Hostname/IP does not match certificate's altnames: Host: wild.example.com. is not in the cert's altnames: DNS:lab.example.com, DNS:*.wild.example.com
- curl →
- A wildcard matches exactly one label:
*.wild.example.comcoversa.wild.example.com(measured OK) but notwild.example.comand notdeep.a.wild.example.com(both measured as mismatches). - An IP literal is a separate branch: with no
IP:entry in the SAN, curl saystarget ipv4 address '127.0.0.1'and Python saysIP address mismatch.DNS:entries never cover an IP, andIP:entries never cover a name. - Dialling a name that is in the certificate is the fix, not disabling verification: connecting to
127.0.0.1withservername=lab.example.com(Node),server_hostname=(Python) orcurl --resolvepasses, while-k,check_hostname=FalseandNODE_TLS_REJECT_UNAUTHORIZED=0also “pass” and remove every check. - Legacy path, measured: a certificate with no SAN extension at all still verifies by
CNin curl, Python and Node — all three acceptedCN=cn.example.com. Treat CN-only as a compatibility path, never as an issuance strategy. - Offline triage that needs no server:
openssl verify -CAfile ca.pem -verify_hostname NAME cert.pemandopenssl x509 -in cert.pem -noout -checkhost NAME(exit 1 on mismatch). Run them in CI before the certificate ever reaches a load balancer. - Inline check against a live endpoint:
openssl s_client -connect host:443 -servername host </dev/null 2>/dev/null | openssl x509 -noout -ext subjectAltNameprints the names the server will actually present for that SNI.
The check is name versus SAN, and it runs last
A TLS handshake has three layers of decision, and the error string tells you which one failed:
| Layer | Question | Failure family | Example string |
|---|---|---|---|
| Framing | Is this even a TLS record? | record layer | wrong version number, record layer failure |
| Trust | Does the chain reach a trusted anchor? | verify error 18/19/20/21 | self-signed certificate in certificate chain |
| Identity | Is the name I dialled in this certificate? | hostname mismatch | Hostname mismatch, certificate is not valid for … |
The identity step is deliberately the last one. The client must validate the chain first, because the SAN list of an untrusted certificate is worthless. That ordering is why a hostname mismatch never appears next to a trust error: by the time the client compares names, the certificate is already trusted.
Practical consequence: if you are staring at a hostname mismatch, stop looking at the CA bundle, the expiry date, the intermediate chain and the private key. Those all passed. The comparison is a string match, and the only two inputs are the name you dialled and the names in the certificate.
When the failure is on the trust layer instead, the strings look completely different — that family is covered in self-signed certificate in certificate chain: Fix curl 60 and in the client-certificate variant tlsv13 alert certificate required: mTLS client cert fix. There is also a third family where the bytes on the wire are not TLS at all: SSL_ERROR_RX_RECORD_TOO_LONG.
Building the lab: one CA, two leaves
Everything below comes from this setup. The leaf deliberately covers a one-label wildcard and no IP address, which is the combination that produces the most confusing real-world failures: an operator with a valid certificate who keeps getting a mismatch because they dial an IP, or a bare domain, or one level too deep.
# CA - note the keyUsage extension: OpenSSL strict mode rejects a CA without it
openssl req -x509 -newkey rsa:2048 -nodes -days 3 \
-subj "/CN=c2cz lab CA" -keyout ca.key -out ca.pem \
-addext "basicConstraints=critical,CA:TRUE" \
-addext "keyUsage=critical,keyCertSign,cRLSign"
# the certificate under test: CN=lab.example.com, SAN carries the apex and one wildcard
cat > san.cnf <<'EOF'
basicConstraints=critical,CA:FALSE
keyUsage=critical,digitalSignature,keyEncipherment
extendedKeyUsage=serverAuth
subjectAltName=DNS:lab.example.com,DNS:*.wild.example.com
EOF
openssl req -new -newkey rsa:2048 -nodes -subj "/CN=lab.example.com" \
-keyout leaf.key -out leaf.csr
openssl x509 -req -in leaf.csr -CA ca.pem -CAkey ca.key -CAcreateserial \
-out leaf.pem -days 3 -extfile san.cnf
# the legacy variant: a CN and no SAN extension at all
openssl req -new -newkey rsa:2048 -nodes -subj "/CN=cn.example.com" \
-keyout cnonly.key -out cnonly.csr
openssl x509 -req -in cnonly.csr -CA ca.pem -CAkey ca.key \
-out cnonly.pem -days 3 \
-extfile <(printf 'basicConstraints=critical,CA:FALSE\nextendedKeyUsage=serverAuth\n')
The two certificates differ in one extension, and that difference decides the whole article:
$ openssl x509 -in leaf.pem -noout -subject -issuer -ext subjectAltName
subject=CN=lab.example.com
issuer=CN=c2cz lab CA
X509v3 Subject Alternative Name:
DNS:lab.example.com, DNS:*.wild.example.com
$ openssl x509 -in cnonly.pem -noout -subject -ext subjectAltName
No extensions in certificate
subject=CN=cn.example.com
Because the leaf has no IP: entry, a client that connects to 127.0.0.1 can never match it, no matter how correct the certificate is. That is the most common production form of this bug: an internal service reached by IP, with a certificate issued for its hostname.
Four clients, four strings, one cause
Two origins serve the two certificates on separate ports, so a probe can choose both the name it dials and the certificate it receives. The -naccept counter makes each server exit on its own, which matters when a probe fails early and nothing else would ever close the socket:
# one origin per certificate; -naccept keeps them self-terminating
timeout 180 openssl s_server -accept 19450 -cert leaf.pem -key leaf.key -www \
-quiet -naccept 40 -tls1_2 &
SRV1=$!
timeout 180 openssl s_server -accept 19451 -cert cnonly.pem -key cnonly.key -www \
-quiet -naccept 10 -tls1_2 &
SRV2=$!
sleep 1.5 # s_server needs a moment to bind before the first probe
# --resolve makes curl dial 127.0.0.1 while claiming a hostname, so the SAN check
# is exercised with the trust layer already satisfied by --cacert
r() {
local label="$1"; shift; local url="$1"; shift
curl -sS --cacert ca.pem "$@" -o /dev/null "$url" 2>&1 | head -n 1
echo "rc=${PIPESTATUS[0]}"
}
r "exact SAN" https://lab.example.com:19450/ --resolve lab.example.com:19450:127.0.0.1
r "IP literal" https://127.0.0.1:19450/
r "one label" https://a.wild.example.com:19450/ --resolve a.wild.example.com:19450:127.0.0.1
r "bare domain" https://wild.example.com:19450/ --resolve wild.example.com:19450:127.0.0.1
r "two labels" https://deep.a.wild.example.com:19450/ --resolve deep.a.wild.example.com:19450:127.0.0.1
r "unrelated" https://other.example.com:19450/ --resolve other.example.com:19450:127.0.0.1
kill $SRV1 $SRV2
Measured output on curl 8.14.1 — note that the failure text is identical for the bare domain, the two-label name and the unrelated name, because all three are simply “not in the list”:
--- exact SAN
rc=0
--- IP literal
curl: (60) SSL: no alternative certificate subject name matches target ipv4 address '127.0.0.1'
rc=60
--- one label
rc=0
--- bare domain
curl: (60) SSL: no alternative certificate subject name matches target hostname 'wild.example.com'
rc=60
--- two labels
curl: (60) SSL: no alternative certificate subject name matches target hostname 'deep.a.wild.example.com'
rc=60
--- unrelated
curl: (60) SSL: no alternative certificate subject name matches target hostname 'other.example.com'
rc=60
Two details are worth stealing from that transcript. First, curl's wording changes with the type of the dialled name: target hostname for a DNS name, target ipv4 address for an address literal. Second, exit code 60 means “peer certificate cannot be authenticated with known CA certificates” — the same code curl uses for trust failures, which is why the message text matters more than the code when you grep your logs.
The same matrix in Python 3.13.5, this time with the origin spawned by the script so the block runs standalone. Python's exception carries the offending name inline, which makes it the most useful string of the four for logging:
"""TLS hostname-verification lab, self-contained: spawns the origin, then probes seven names."""
import signal
import socket
import ssl
import subprocess
import sys
import time
D = "/opt/data/c2cz/lab/20260924-san"
CA = f"{D}/ca.pem"
def origin(port, cert, key, naccept=40):
"""One openssl s_server per certificate. Self-terminating after naccept connections."""
return subprocess.Popen(
["openssl", "s_server", "-accept", str(port), "-cert", cert, "-key", key,
"-www", "-quiet", "-naccept", str(naccept), "-tls1_2"],
cwd=D, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL,
)
srv = [origin(19450, "leaf.pem", "leaf.key"), origin(19451, "cnonly.pem", "cnonly.key")]
time.sleep(1.5) # s_server needs a moment to bind before the first probe
CASES = [
("lab.example.com", 19450, "SAN exact match"),
("127.0.0.1", 19450, "IP literal, no IP SAN in cert"),
("a.wild.example.com", 19450, "wildcard, one label"),
("wild.example.com", 19450, "bare domain vs *.wild.example.com"),
("deep.a.wild.example.com", 19450, "two labels deep"),
("other.example.com", 19450, "unrelated name"),
("cn.example.com", 19451, "CN-only cert, matching CN"),
]
def probe(server_hostname, port, check_hostname=True):
ctx = ssl.create_default_context(cafile=CA)
ctx.check_hostname = check_hostname
if not check_hostname:
ctx.verify_mode = ssl.CERT_NONE
try:
with socket.create_connection(("127.0.0.1", port), timeout=5) as raw:
with ctx.wrap_socket(raw, server_hostname=server_hostname) as tls:
san = [v for k, v in tls.getpeercert().get("subjectAltName", ()) if k == "DNS"]
return f"OK {tls.version()} SAN={san}"
except Exception as exc: # noqa: BLE001 - the message text is the payload
return f"FAIL {type(exc).__name__}: {exc}"
try:
print(f"python {sys.version.split()[0]} {ssl.OPENSSL_VERSION}")
for host, port, label in CASES:
print(f"--- {label}\n server_hostname={host!r} : {probe(host, port)}")
print("--- INVERSION: check_hostname=False on a name that is not in the cert")
print(f" server_hostname='other.example.com' : "
f"{probe('other.example.com', 19450, check_hostname=False)}")
finally:
for p in srv:
p.send_signal(signal.SIGTERM)
python 3.13.5 OpenSSL 3.5.6 7 Apr 2026
--- SAN exact match
server_hostname='lab.example.com' : OK TLSv1.2 SAN=['lab.example.com', '*.wild.example.com']
--- IP literal, no IP SAN in cert
server_hostname='127.0.0.1' : FAIL SSLCertVerificationError: [SSL: CERTIFICATE_VERIFY_FAILED] certificate verify failed: IP address mismatch, certificate is not valid for '127.0.0.1'. (_ssl.c:1029)
--- wildcard, one label
server_hostname='a.wild.example.com' : OK TLSv1.2 SAN=['lab.example.com', '*.wild.example.com']
--- bare domain vs *.wild.example.com
server_hostname='wild.example.com' : FAIL SSLCertVerificationError: [SSL: CERTIFICATE_VERIFY_FAILED] certificate verify failed: Hostname mismatch, certificate is not valid for 'wild.example.com'. (_ssl.c:1029)
--- two labels deep
server_hostname='deep.a.wild.example.com' : FAIL SSLCertVerificationError: [SSL: CERTIFICATE_VERIFY_FAILED] certificate verify failed: Hostname mismatch, certificate is not valid for 'deep.a.wild.example.com'. (_ssl.c:1029)
--- unrelated name
server_hostname='other.example.com' : FAIL SSLCertVerificationError: [SSL: CERTIFICATE_VERIFY_FAILED] certificate verify failed: Hostname mismatch, certificate is not valid for 'other.example.com'. (_ssl.c:1029)
--- CN-only cert, matching CN
server_hostname='cn.example.com' : OK TLSv1.2 SAN=[]
--- INVERSION: check_hostname=False on a name that is not in the cert
server_hostname='other.example.com' : OK TLSv1.2 SAN=[]
Python has a second trap here: ssl.match_hostname() was deprecated and is now gone. Measured on 3.13.5, hasattr(ssl, 'match_hostname') is False and any call raises AttributeError: module 'ssl' has no attribute 'match_hostname'. Code that hand-rolled a hostname check against a parsed certificate has to move to ctx.check_hostname = True, which is the default of ssl.create_default_context() anyway.
Node is the outlier: the same mistake surfaces as a generic TypeError: fetch failed with the real reason buried in err.cause. Printing err.message alone — which most logging middleware does — throws away the only useful part:
// TLS hostname-verification lab, self-contained: spawns the origin, then probes six names.
// Run as: NODE_EXTRA_CA_CERTS=ca.pem node node-probe-selfcontained.mjs
import { spawn } from "node:child_process";
import fs from "node:fs";
import https from "node:https";
const D = "/opt/data/c2cz/lab/20260924-san";
const ca = fs.readFileSync(`${D}/ca.pem`);
const origin = (port, cert, key, naccept = 40) =>
spawn("openssl", ["s_server", "-accept", String(port), "-cert", cert, "-key", key,
"-www", "-quiet", "-naccept", String(naccept), "-tls1_2"],
{ cwd: D, stdio: "ignore" });
const srv = [origin(19450, "leaf.pem", "leaf.key"), origin(19451, "cnonly.pem", "cnonly.key")];
await new Promise((r) => setTimeout(r, 1500)); // let s_server bind
// https.request honours `servername` for both SNI and hostname verification.
function probe(servername, port = 19450) {
return new Promise((resolve) => {
const req = https.request(
{ host: "127.0.0.1", port, servername, ca, path: "/", method: "GET",
headers: { Host: servername } },
(res) => { res.resume(); resolve(`OK status=${res.statusCode}`); },
);
req.on("error", (err) => resolve(`FAIL ${err.code}: ${err.message}`));
req.end();
});
}
const CASES = [
["lab.example.com", 19450, "SAN exact match"],
["a.wild.example.com", 19450, "wildcard, one label"],
["wild.example.com", 19450, "bare domain vs *.wild.example.com"],
["deep.a.wild.example.com", 19450, "two labels deep"],
["other.example.com", 19450, "unrelated name"],
["cn.example.com", 19451, "CN-only cert, matching CN"],
];
try {
console.log(`node ${process.version}`);
for (const [host, port, label] of CASES) {
console.log(`--- ${label}\n servername=${host} -> ${await probe(host, port)}`);
}
// fetch() has no servername knob, so it verifies the URL host - here an IP literal.
try {
const res = await fetch("https://127.0.0.1:19450/");
console.log(`--- fetch() to the IP literal -> OK status=${res.status}`);
} catch (err) {
console.log(`--- fetch() to the IP literal -> TypeError: ${err.message} `
+ `| cause=${err.cause?.code}: ${err.cause?.message}`);
}
} finally {
for (const p of srv) p.kill("SIGTERM");
}
node v26.5.1
--- SAN exact match
servername=lab.example.com -> OK status=200
--- wildcard, one label
servername=a.wild.example.com -> OK status=200
--- bare domain vs *.wild.example.com
servername=wild.example.com -> FAIL ERR_TLS_CERT_ALTNAME_INVALID: Hostname/IP does not match certificate's altnames: Host: wild.example.com. is not in the cert's altnames: DNS:lab.example.com, DNS:*.wild.example.com
--- two labels deep
servername=deep.a.wild.example.com -> FAIL ERR_TLS_CERT_ALTNAME_INVALID: Hostname/IP does not match certificate's altnames: Host: deep.a.wild.example.com. is not in the cert's altnames: DNS:lab.example.com, DNS:*.wild.example.com
--- unrelated name
servername=other.example.com -> FAIL ERR_TLS_CERT_ALTNAME_INVALID: Hostname/IP does not match certificate's altnames: Host: other.example.com. is not in the cert's altnames: DNS:lab.example.com, DNS:*.wild.example.com
--- CN-only cert, matching CN
servername=cn.example.com -> OK status=200
--- fetch() to the IP literal -> TypeError: fetch failed | cause=ERR_TLS_CERT_ALTNAME_INVALID: Hostname/IP does not match certificate's altnames: IP: 127.0.0.1 is not in the cert's list:
Node's message is the only one that prints the whole SAN list back at you, which is why it is the fastest of the four to diagnose: is not in the cert's altnames: DNS:lab.example.com, DNS:*.wild.example.com tells you exactly what the certificate offers. It also has two shapes, and the difference is load-bearing: Host: NAME. is not in the cert's altnames when the dialled target is a name (note the trailing dot after the name), and IP: 127.0.0.1 is not in the cert's list: when it is an address, with an empty list because this certificate has no IP: SAN entries at all.
Offline triage: prove the mismatch before you connect
You do not need a running server to reproduce this. openssl verify performs the same identity check on a file, and it is the version you can put in CI. Note the two distinct verify error numbers: 62 for a name and 64 for an address:
$ openssl verify -CAfile ca.pem -verify_hostname lab.example.com leaf.pem
leaf.pem: OK
$ openssl verify -CAfile ca.pem -verify_hostname a.wild.example.com leaf.pem
leaf.pem: OK
$ openssl verify -CAfile ca.pem -verify_hostname wild.example.com leaf.pem
CN=lab.example.com
error 62 at 0 depth lookup: hostname mismatch
error leaf.pem: verification failed
$ openssl verify -CAfile ca.pem -verify_hostname deep.a.wild.example.com leaf.pem
CN=lab.example.com
error 62 at 0 depth lookup: hostname mismatch
error leaf.pem: verification failed
$ openssl verify -CAfile ca.pem -verify_ip 127.0.0.1 leaf.pem
CN=lab.example.com
error 64 at 0 depth lookup: IP address mismatch
error leaf.pem: verification failed
$ openssl verify -CAfile ca.pem leaf.pem
leaf.pem: OK
The last command is the important control: without -verify_hostname or -verify_ip, OpenSSL only checks the chain and the certificate is OK. If your monitoring pipeline runs openssl verify without a name argument, it will happily bless a certificate that matches nothing you serve. The exit status carries the verdict — 0 for OK, 2 for a failed verification — so it drops straight into a shell test.
openssl x509 -checkhost and -checkip are the terser equivalents, and they answer with an English sentence rather than a verify code:
$ openssl x509 -in leaf.pem -noout -checkhost lab.example.com
Hostname lab.example.com does match certificate
$ echo $?
0
$ openssl x509 -in leaf.pem -noout -checkhost wild.example.com
Hostname wild.example.com does NOT match certificate
$ echo $?
1
$ openssl x509 -in leaf.pem -noout -checkip 127.0.0.1
IP 127.0.0.1 does NOT match certificate
$ echo $?
1
Applied to a deployment, that becomes a five-line precondition. The list of names is the set of hostnames your clients actually dial, not the set you wish they dialled:
#!/usr/bin/env bash
# Fail the deploy if the certificate does not cover every name the clients use.
# Usage: cert_covers.sh cert.pem name1 name2 ...
set -u
cert="$1"; shift
rc=0
for name in "$@"; do
if openssl x509 -in "$cert" -noout -checkhost "$name" >/dev/null 2>&1; then
echo "ok $name"
else
echo "MISMATCH $name"; rc=1
fi
done
exit "$rc"
Against a live endpoint the check runs over the wire, and it must be done with SNI, because the certificate a server presents depends on the name in the ClientHello. Without -servername you inspect the default certificate and can end up debugging a certificate that is never served to your clients:
# right name: verification succeeds, exit 0
$ openssl s_client -connect 127.0.0.1:19452 -servername lab.example.com \
-verify_hostname lab.example.com -verify_return_error -CAfile ca.pem </dev/null
Protocol: TLSv1.2
Verify return code: 0 (ok)
# wrong name: two lines, both naming the check that failed
$ openssl s_client -connect 127.0.0.1:19452 -servername other.example.com \
-verify_hostname other.example.com -verify_return_error -CAfile ca.pem </dev/null
verify error:num=62:hostname mismatch
Verification error: hostname mismatch
# what the server presents for that SNI, in isolation
$ openssl s_client -connect 127.0.0.1:19452 -servername lab.example.com -CAfile ca.pem \
</dev/null 2>/dev/null | openssl x509 -noout -ext subjectAltName
X509v3 Subject Alternative Name:
DNS:lab.example.com, DNS:*.wild.example.com
-verify_return_error is what makes this useful in a script: without it s_client reports the verification error and still exits 0, so a naive if openssl s_client … test passes on a broken name.
The fix is a name, not a flag
There are only two correct repairs, and both of them change which name is compared.
1. Issue a certificate whose SAN covers every name you dial. The SAN is the list; the CN is legacy. If the service is reached as api.internal.example.com, an IP: dial cannot match it, and the certificate has to carry IP:10.0.0.7 as well if you insist on dialling the address. With an ACME client the fix is one flag per name, which is also what makes the wildcard trap bite: a request for the apex covers the apex only.
# every name the clients use, in one certificate
certbot certonly --nginx -d example.com -d www.example.com -d api.example.com
# the trap: *.example.com does NOT cover example.com, and not a.b.example.com
certbot certonly --nginx -d '*.example.com'
# generating by hand, the SAN is an explicit extension - not the CN
openssl req -new -newkey rsa:2048 -nodes -subj "/CN=api.example.com" \
-addext "subjectAltName=DNS:api.example.com,IP:10.0.0.7" \
-keyout api.key -out api.csr
2. Dial a name that is in the certificate. When you must connect through a load balancer address or over a private network, keep the address for routing and the hostname for verification. All four clients can do that; the flag differs, and none of them requires disabling verification.
# curl: resolve the real name to the address, keep the name in SNI and in the check
curl --resolve api.example.com:443:10.0.0.7 https://api.example.com/health
# python: create_connection dials the address, server_hostname carries the identity
python3 - <<'EOF'
import socket, ssl
ctx = ssl.create_default_context(cafile="ca.pem") # check_hostname is True by default
with socket.create_connection(("127.0.0.1", 19450), timeout=5) as raw:
with ctx.wrap_socket(raw, server_hostname="lab.example.com") as tls:
print("verified:", tls.version(), tls.getpeercert()["subjectAltName"])
EOF
# node: --input-type=module is required to read ESM from stdin; servername drives
# both SNI and the altname check
node --input-type=module - <<'EOF'
import https from "node:https";
const req = https.request({ host: "127.0.0.1", port: 19450, servername: "lab.example.com",
path: "/" }, (res) => { console.log("status", res.statusCode); res.resume(); });
req.on("error", (err) => console.error(err.code, err.message));
req.end();
EOF
What does not fix it is the escape hatch, and this is where the measured matrix earns its place: every one of these three lines makes the failure disappear while removing the check that just saved you. They are, in order, curl's -k, Python's check_hostname = False and Node's NODE_TLS_REJECT_UNAUTHORIZED=0:
# the same mismatching name that failed above, now with verification turned off
$ curl -sS --cacert ca.pem --resolve other.example.com:19450:127.0.0.1 -k \
https://other.example.com:19450/ -o /dev/null; echo "rc=$?"
rc=0
# python: ctx.check_hostname = False plus ctx.verify_mode = ssl.CERT_NONE
# -> the same probe that raised SSLCertVerificationError now reports
# server_hostname='other.example.com' : OK TLSv1.2 SAN=[]
# node: NODE_TLS_REJECT_UNAUTHORIZED=0
$ NODE_TLS_REJECT_UNAUTHORIZED=0 node node-probe-selfcontained.mjs
--- fetch() to the IP literal -> OK status=200
(node:1427015) Warning: Setting the NODE_TLS_REJECT_UNAUTHORIZED environment variable
to '0' makes TLS connections and HTTPS requests insecure by disabling certificate verification.
Node is at least explicit about it: the warning is printed to stderr once per process, and it is the only one of the three that tells you in the log that verification is off. curl's -k and Python's check_hostname = False are silent. If you find one of these in a service definition, the honest fix is a certificate that names the endpoint — the mismatch was real, and so was the protection.
Triage order that converges in one pass
- Read the string, not the exit code.
hostname mismatch/Hostname mismatch, certificate is not valid for X/ERR_TLS_CERT_ALTNAME_INVALID/no alternative certificate subject name matchesall mean identity.error 19,unable to get local issuer certificateorself-signed certificatemean trust. - Print the SAN list of the exact certificate that is served for your SNI:
openssl s_client -connect host:443 -servername host </dev/null 2>/dev/null | openssl x509 -noout -ext subjectAltName. - Compare the dialled name byte by byte against that list, including the wildcard rules: one label only, and never the apex.
- If you dial an address, check for an
IP:entry — and remember thaterror 64andIP address mismatchare the address branch of the same check. - Fix the name or the certificate. Do not reach for
-k. - Put
openssl x509 -noout -checkhostin CI, andopenssl s_client -verify_hostname … -verify_return_errorin the post-deploy smoke test, so the next mismatch fails a pipeline instead of an incident.
Everything in this article was executed on the lab host described above; the two self-contained client scripts and the certificate setup are the exact programs behind every quoted line, and the wildcard and CN behaviours were confirmed by running each name against each certificate rather than by reasoning about the specification.