Ch. 20 · Networking

TLS Certificate Validation: Why the Hostname Check Matters

Why encryption without a hostname check protects nothing, what a client verifies in a certificate, and the right fix for each TLS error.

~6 min readbeginnerupdated Oct 11, 2026

Ask someone what HTTPS does and you’ll usually hear “it encrypts the traffic”. That’s true, and it’s the less interesting half. Encryption with an unknown party is worth very little: if an attacker can sit between you and the server, they can happily negotiate encryption with you and separately with the real server, reading everything in the middle. What makes TLS useful is that the client can prove it’s talking to the server it meant to reach.

Interviewers probe this with questions like “what does the client actually check in a certificate?” and “why does a valid certificate still fail?” This note answers them with openssl against a public site and a local Node HTTPS server.

Encryption needs an identity attached

A TLS 1.3 handshake does two jobs at once. The key exchange (ephemeral Diffie-Hellman) lets client and server agree on a secret without ever sending it, so a passive eavesdropper learns nothing. But that exchange works equally well with an attacker. On its own, it only proves you share a secret with someone.

The certificate closes that gap. It binds a public key to one or more hostnames, and it’s signed by a certificate authority (CA) the client already trusts. During the handshake, the server signs the handshake transcript with the matching private key. So the client learns three things together: this key belongs to example.com according to a CA I trust, the server holds the private key, and the secret we just agreed on was negotiated with that server and nobody else.

Take any of those away and the encryption is still there, but you no longer know who’s on the other end.

What the client checks, using a real certificate

openssl s_client shows what a client receives:

openssl s_client -connect example.com:443 -servername example.com </dev/null
Terminal
 0 s:CN=example.com
   i:C=US, O=SSL Corporation, CN=Cloudflare TLS Issuing ECC CA 3
 1 s:C=US, O=SSL Corporation, CN=Cloudflare TLS Issuing ECC CA 3
   i:C=US, O=SSL Corporation, CN=SSL.com TLS Transit ECC CA R2
 2 s:C=US, O=SSL Corporation, CN=SSL.com TLS Transit ECC CA R2
   i:C=US, O=SSL Corporation, CN=SSL.com TLS ECC Root CA 2022
...
Protocol: TLSv1.3
Verify return code: 0 (ok)
Text

And the parts of the leaf certificate that matter for identity:

openssl s_client -connect example.com:443 -servername example.com </dev/null 2>/dev/null \
  | openssl x509 -noout -ext subjectAltName -dates
Terminal
X509v3 Subject Alternative Name:
    DNS:example.com, DNS:*.example.com
notBefore=Sep 26 22:49:11 2026 GMT
notAfter=Dec 25 22:56:35 2026 GMT
Text

The client walks through these in turn:

  1. The chain. Each certificate (s: is the subject) is signed by the next one up (i: is the issuer). The client follows the signatures until it reaches a root in its own trust store. The server is expected to send the intermediates; the root is already on the client.
  2. The dates. The current time has to be between notBefore and notAfter. This certificate is valid for about 90 days, which is now typical.
  3. The hostname. The name the client asked for must match an entry in the Subject Alternative Name list. Modern clients ignore the old Common Name field for this.

Wildcards cover exactly one label. Checking the same certificate against different names:

openssl s_client -connect example.com:443 -servername www.example.com -verify_hostname www.example.com </dev/null 2>/dev/null | grep 'Verify return'
openssl s_client -connect example.com:443 -servername example.com -verify_hostname deep.sub.example.com </dev/null 2>/dev/null | grep 'Verify return'
Terminal
Verify return code: 0 (ok)
Verify return code: 62 (hostname mismatch)
Text

*.example.com matches www.example.com but not deep.sub.example.com.

The -servername flag sets SNI (Server Name Indication), the hostname the client sends in its first handshake message so a server hosting many sites knows which certificate to present. SNI travels unencrypted in most deployments, so a network observer can see which site you’re visiting even though they can’t see the page.

The hostname check is the whole point

Here’s the scenario that tests understanding: a server presents a perfectly valid certificate, signed by a trusted CA, unexpired, but issued for a different hostname. Should the connection succeed?

No. Anyone can get a free, valid certificate for a domain they control. If clients accepted any trusted certificate regardless of name, an attacker who could redirect your traffic (a hostile Wi-Fi network, a poisoned DNS cache) could present their own certificate for attacker.test and pass. The chain proves the certificate is genuine. Only the hostname check proves it’s genuine for the site you asked for.

To see the failures locally, a self-signed certificate for localhost (with 127.0.0.1 in its SAN list) went into a small Node HTTPS server:

openssl req -x509 -newkey ec -pkeyopt ec_paramgen_curve:prime256v1 -nodes -days 30 \
  -subj "/CN=localhost" -addext "subjectAltName=DNS:localhost,IP:127.0.0.1" \
  -keyout key.pem -out cert.pem
Terminal
import https from 'node:https';
import { readFileSync } from 'node:fs';

https.createServer(
  { key: readFileSync('key.pem'), cert: readFileSync('cert.pem') },
  (req, res) => res.end(`hello over ${req.socket.getProtocol()}\n`),
).listen(8443, '127.0.0.1');
JavaScript

Then curl, three ways:

curl -sS https://localhost:8443/
curl -sS --cacert cert.pem https://localhost:8443/
curl -sS --cacert cert.pem --resolve api.other.test:8443:127.0.0.1 https://api.other.test:8443/
Terminal
curl: (60) SSL certificate problem: self signed certificate
hello over TLSv1.3
curl: (60) SSL: no alternative certificate subject name matches target host name 'api.other.test'
Text

The first fails on trust: nothing in the system store vouches for this certificate. The second succeeds because --cacert tells curl to trust exactly this one. The third connects to the same server with the same trusted certificate, and still fails, because the client asked for api.other.test and the certificate doesn’t name it. Node reports the same cases as DEPTH_ZERO_SELF_SIGNED_CERT and ERR_TLS_CERT_ALTNAME_INVALID.

Turning verification off is how outages become breaches

Every one of those errors has a tempting one-line escape: curl -k, rejectUnauthorized: false, NODE_TLS_REJECT_UNAUTHORIZED=0, verify=False in Python. With -k, the third request above returns hello over TLSv1.3. The traffic is still encrypted, which is exactly why this is dangerous: everything looks fine, and the client will now accept anybody’s certificate.

The real fixes depend on which check failed:

  • Unknown issuer (an internal CA or a self-signed development certificate): add that CA to the client’s trust, with --cacert, Node’s ca option or NODE_EXTRA_CA_CERTS. Trust one CA, not everything.
  • “unable to verify the first certificate”: almost always the server isn’t sending its intermediate certificate. Browsers often hide this because they cache or fetch missing intermediates, so the site works in Chrome and fails in curl, Java or Node. Fix the server’s certificate bundle.
  • Hostname mismatch: issue a certificate with the right SAN entries, or connect using the name the certificate covers.
  • Expired: renew, and automate renewal so it doesn’t happen again.

It’s also worth knowing what a successful check doesn’t mean. A phishing site can have a valid certificate for its own lookalike domain. TLS protects the connection up to wherever it terminates, often a load balancer, and traffic beyond that point may not be encrypted at all.

Interview practice

After a routine certificate rotation, one of your Node services starts failing calls to an internal HTTPS API with “unable to verify the first certificate”. Browsers load the API’s docs page fine. A teammate’s pull request sets rejectUnauthorized: false to get things working. How do you respond?

Answer

I’d block that change, because it makes the client accept any certificate from anyone, which removes server authentication for every call that client makes. The error message points at a specific problem: Node received the leaf certificate but couldn’t build a chain to a trusted root. Browsers working while Node fails is a strong sign the new deployment is missing its intermediate certificate, since browsers often fill that gap themselves.

To confirm, I’d run openssl s_client -connect api.internal:443 -servername api.internal -showcerts and count the certificates the server sends. If only the leaf comes back, the fix is to deploy the full chain (leaf plus intermediates) on the server or load balancer. If the chain is complete but ends in a private root, the fix is giving the service that root through the ca option or NODE_EXTRA_CA_CERTS. Either way I’d add a check to the rotation process, such as an openssl or curl call from a non-browser client, so the next rotation catches it before release.

More on this topic

More in Networking

read ✓Networking · hard

Networking: The TLS Handshake

Read a real TLS 1.3 handshake from openssl s_client, compare it with TLS 1.2, and diagnose the certificate errors that break HTTPS clients.

~6 min readread →
read ✓Networking · hard

Network Debugging Along the Request Path

Find the first failing step in a request (DNS, TCP, TLS, HTTP or the app) with curl timings, dig, nc and openssl, using six real failures.

~6 min readread →
read ✓Networking · mid

Networking: DNS Caching and TTL

See how resolvers count TTLs down, why NXDOMAIN answers are cached too, and how to schedule a DNS cutover so stale answers expire.

~7 min readread →
esc