Diagnostic TLS Certificates / CAA
TLS certificate SAN or hostname mismatch
A visitor opens api.example.com and the browser stops them cold with a certificate warning, even though example.com and www.example.com load fine. The certificate is real and not expired. It just was never told about this hostname. Here is why that happens and how to reissue a certificate that actually covers every name you serve.
The server handed back a real certificate, but that certificate's Subject Alternative Name (SAN) list does not include the hostname the client actually asked for. Modern browsers and TLS libraries ignore the old Common Name field completely and only trust the dNSName entries in the SAN extension, so any hostname missing from that list fails with an error like ERR_CERT_COMMON_NAME_INVALID or "hostname mismatch," even on a certificate that is otherwise perfectly valid. Reissue the certificate with every hostname you actually serve listed as a SAN, or, on Cloudflare, add the hostname through Total TLS or an Advanced Certificate. Full code, tests, and a dry run guard are below.
The problem in plain words
A TLS certificate does not just say "this server is trustworthy." It says "this server is trustworthy for exactly these hostnames," and lists them by name inside an extension called Subject Alternative Name, or SAN. When your browser connects to a hostname, it checks that name against that list. If the name is not there, word for word or matched by a wildcard, the browser refuses the connection and shows a mismatch error, no matter how valid the certificate is otherwise.
This trips people up because a certificate can look completely fine at a glance. It is issued by a real authority, it has not expired, the chain is trusted. The only thing wrong is that nobody told it about the new hostname. That happens most often right after adding a subdomain, moving a service behind a load balancer that already serves a different tenant's certificate, or renaming something and forgetting the cert followed along.
Why it happens
- A new subdomain, apex, or www variant went live without anyone reissuing the certificate to include it, per the naming rules in RFC 6125 and its successor RFC 9525.
- DNS was pointed at a load balancer or CDN edge that already terminates TLS for a different tenant, so it serves that tenant's certificate and SAN list instead of yours.
- A server handling multiple certificates picked the wrong one for the requested name because SNI (Server Name Indication) was not configured to route it correctly.
- A service was renamed or moved to a new hostname, and the old certificate, still valid and still attached, was never swapped out for one that lists the new name.
None of this is a browser bug. RFC 6125 and RFC 9525 both say that Common Name based matching is deprecated and that clients must validate against the SAN dNSName entries. Browsers and TLS libraries follow that rule strictly, which is why a certificate can be entirely valid and still fail for one specific hostname.
A certificate is not "valid for this domain" in a general sense. It is valid only for the exact hostnames written into its SAN list, checked one name at a time, with wildcards covering exactly one label depth and nothing deeper. Adding a hostname anywhere in your infrastructure means adding it to a certificate's SAN list too, or the two will quietly disagree the first time someone visits it.
The fix, as a flow
Find every hostname that is actually being served, then reissue or expand the certificate so its SAN list names all of them, including the ones that already worked. On a CDN like Cloudflare, either lean on the wildcard edge certificate for names it already covers, or turn on automatic per-hostname issuance, or add the missing host to an Advanced Certificate's host list. Reload the TLS-terminating service so the new certificate actually gets served for that name.
How to fix it
Confirm which endpoint actually answers first
Before touching any certificate, make sure DNS is pointed where you think it is. A hostname mismatch is sometimes really a "wrong server entirely" problem, and no amount of certificate work fixes that.
dig +short A example.com
dig +short CNAME app.example.com
# confirm both names resolve to the endpoint you expect before touching any certificate
Read the SAN list the server actually serves for that hostname
Connect with SNI set to the exact hostname that is failing and print the SAN extension. A bad result is the requested hostname missing from the printed DNS: entries.
openssl s_client -connect example.com:443 -servername app.example.com </dev/null 2>/dev/null \
| openssl x509 -noout -text | grep -A1 'Subject Alternative Name'
# equivalent one liner
echo | openssl s_client -connect example.com:443 -servername app.example.com 2>/dev/null \
| openssl x509 -noout -ext subjectAltName
# bad result: only DNS:www.example.com, DNS:example.com printed, app.example.com missing
# nmap alternative
nmap --script ssl-cert -p 443 example.com
Reissue the certificate with every hostname listed
List all existing SAN names plus the missing one, or use a flag built to keep the old ones automatically. Dropping a name that was already there breaks whatever hostname used to rely on it.
# Let's Encrypt / certbot: list every existing SAN plus the new one
certbot certonly --expand --cert-name example.com \
-d example.com -d www.example.com -d app.example.com
# --expand keeps the previously issued SANs automatically if you forget one
On Cloudflare, cover the hostname at the edge instead
If the site sits behind Cloudflare, the free Universal SSL edge certificate already covers the apex plus one level of wildcard subdomain. For hostnames beyond that reach, turn on Total TLS to auto-issue per-hostname certificates, or add the host explicitly to an Advanced Certificate.
# Order an Advanced Certificate with the full hosts array, including the missing name
curl -s -X POST "https://api.cloudflare.com/client/v4/zones/ZONE_ID/ssl/certificate_packs" \
-H "Authorization: Bearer $CLOUDFLARE_API_TOKEN" \
-H "Content-Type: application/json" \
--data '{
"type": "advanced",
"hosts": ["example.com", "www.example.com", "app.example.com"],
"validation_method": "txt",
"validity_days": 90,
"certificate_authority": "lets_encrypt"
}'
# poll status until it reports active
curl -s "https://api.cloudflare.com/client/v4/zones/ZONE_ID/ssl/certificate_packs/CERT_PACK_ID" \
-H "Authorization: Bearer $CLOUDFLARE_API_TOKEN"
Reload the TLS-terminating service
Reissuing the certificate is not enough on its own. Restart or reload nginx, attach the new ACM certificate to the load balancer listener, or wait for the Cloudflare edge to pick up the new cert pack, so the new SAN list is actually the one served during the handshake.
Always pass the complete set of hostnames when reissuing, or use a flag like certbot's --expand that keeps the old ones for you. Forgetting an existing SAN entry trades one mismatch for another.
How to check it worked
Re-run the SAN check against the hostname that used to fail, then do a full handshake with curl to confirm no verification error appears.
echo | openssl s_client -connect example.com:443 -servername app.example.com 2>/dev/null \
| openssl x509 -noout -ext subjectAltName
# good result: DNS:app.example.com now present alongside the other names
curl -vI https://app.example.com 2>&1 | grep -i 'subject\|issuer\|SSL certificate problem'
# good result: subject/issuer print normally, no 'SSL certificate problem' line,
# no certificate verify failed or hostname mismatch error, headers return normally
The full code
Here is a small script in each language that opens a TLS connection with SNI set to the target hostname, reads the SAN list off the peer certificate, and checks whether the hostname is covered. When it is not and the zone is on Cloudflare, it can add the hostname to the certificate pack's host list. It stays in dry run by default so it only reports what it would change.
View this code on GitHub Full runnable folder with tests in the dns-fixes repo.
"""Detect a TLS certificate SAN/hostname mismatch and optionally repair it via Cloudflare.
Safe by default. Set DRY_RUN=false to let it write.
"""
import os
import logging
logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(message)s")
log = logging.getLogger("tls_san_hostname_mismatch")
DNS_DOMAIN = os.environ.get("DNS_DOMAIN", "example.com")
CLOUDFLARE_API_TOKEN = os.environ.get("CLOUDFLARE_API_TOKEN", "")
CLOUDFLARE_ZONE_ID = os.environ.get("CLOUDFLARE_ZONE_ID", "")
CLOUDFLARE_CERT_PACK_ID = os.environ.get("CLOUDFLARE_CERT_PACK_ID", "")
DRY_RUN = os.environ.get("DRY_RUN", "true").lower() == "true"
CF_API = "https://api.cloudflare.com/client/v4"
def san_covers_hostname(hostname: str, san_dns_names: list) -> bool:
"""Pure decision function. No I/O.
Given the requested hostname and the list of dNSName strings pulled from
the certificate's SAN extension, normalize case, then return True if the
hostname is an exact match to any entry or matches a leftmost-label
wildcard entry (e.g. '*.example.com' matches 'app.example.com' but not
'a.b.example.com' or the bare apex 'example.com'). Returns False otherwise.
"""
host = hostname.strip().lower().rstrip(".")
names = [n.strip().lower().rstrip(".") for n in san_dns_names]
if host in names:
return True
host_labels = host.split(".")
for name in names:
if not name.startswith("*."):
continue
wildcard_suffix = name[2:]
# Wildcard covers exactly one leftmost label: 'app.example.com' but not
# 'a.b.example.com', and never the bare suffix itself ('example.com').
if len(host_labels) < 3:
continue
remainder = ".".join(host_labels[1:])
if remainder == wildcard_suffix:
return True
return False
def fetch_san_for_hostname(hostname, port=443):
"""Open a TLS connection with SNI set to hostname and return its SAN dNSNames. Requires network."""
import socket
import ssl
ctx = ssl.create_default_context()
with socket.create_connection((hostname, port), timeout=10) as sock:
with ctx.wrap_socket(sock, server_hostname=hostname) as tls:
cert = tls.getpeercert()
names = [value for key, value in cert.get("subjectAltName", ()) if key == "DNS"]
return names
def get_cert_pack_hosts():
"""Read the current hosts array off a Cloudflare Advanced Certificate pack."""
import requests
headers = {"Authorization": f"Bearer {CLOUDFLARE_API_TOKEN}"}
url = f"{CF_API}/zones/{CLOUDFLARE_ZONE_ID}/ssl/certificate_packs/{CLOUDFLARE_CERT_PACK_ID}"
r = requests.get(url, headers=headers, timeout=30)
r.raise_for_status()
return r.json()["result"].get("hosts", [])
def add_hostname_to_cert_pack(hostname):
"""Add the missing hostname as a SAN by patching the Cloudflare certificate pack."""
import requests
if DRY_RUN:
log.info("[dry run] would add %s to certificate pack %s", hostname, CLOUDFLARE_CERT_PACK_ID)
return
headers = {"Authorization": f"Bearer {CLOUDFLARE_API_TOKEN}", "Content-Type": "application/json"}
url = f"{CF_API}/zones/{CLOUDFLARE_ZONE_ID}/ssl/certificate_packs/{CLOUDFLARE_CERT_PACK_ID}"
hosts = get_cert_pack_hosts()
if hostname not in hosts:
hosts = hosts + [hostname]
requests.patch(url, headers=headers, json={"hosts": hosts}, timeout=30).raise_for_status()
log.info("Requested certificate pack update to include %s", hostname)
def run():
hostname = DNS_DOMAIN
san_names = fetch_san_for_hostname(hostname)
log.info("SAN entries served for %s: %s", hostname, san_names)
if san_covers_hostname(hostname, san_names):
log.info("Nothing to repair. %s is already covered.", hostname)
return
log.warning("Hostname %s is missing from the served certificate's SAN list.", hostname)
if not (CLOUDFLARE_API_TOKEN and CLOUDFLARE_ZONE_ID and CLOUDFLARE_CERT_PACK_ID):
log.warning("Mismatch found but no Cloudflare certificate pack credentials set. Skipping repair.")
return
add_hostname_to_cert_pack(hostname)
if __name__ == "__main__":
run()
/**
* Detect a TLS certificate SAN/hostname mismatch and optionally repair it via Cloudflare.
* Safe by default. Set DRY_RUN=false to let it write.
*/
import { pathToFileURL } from "node:url";
const DNS_DOMAIN = process.env.DNS_DOMAIN || "example.com";
const CLOUDFLARE_API_TOKEN = process.env.CLOUDFLARE_API_TOKEN || "";
const CLOUDFLARE_ZONE_ID = process.env.CLOUDFLARE_ZONE_ID || "";
const CLOUDFLARE_CERT_PACK_ID = process.env.CLOUDFLARE_CERT_PACK_ID || "";
const DRY_RUN = (process.env.DRY_RUN || "true").toLowerCase() === "true";
const CF_API = "https://api.cloudflare.com/client/v4";
/**
* Pure decision function. No I/O.
*
* Given the requested hostname and the list of dNSName strings pulled from
* the certificate's SAN extension, normalize case, then return true if the
* hostname is an exact match to any entry or matches a leftmost-label
* wildcard entry (e.g. '*.example.com' matches 'app.example.com' but not
* 'a.b.example.com' or the bare apex 'example.com'). Returns false otherwise.
*/
export function sanCoversHostname(hostname, sanDnsNames) {
const host = hostname.trim().toLowerCase().replace(/\.$/, "");
const names = sanDnsNames.map((n) => n.trim().toLowerCase().replace(/\.$/, ""));
if (names.includes(host)) return true;
const hostLabels = host.split(".");
for (const name of names) {
if (!name.startsWith("*.")) continue;
const wildcardSuffix = name.slice(2);
// Wildcard covers exactly one leftmost label: 'app.example.com' but not
// 'a.b.example.com', and never the bare suffix itself ('example.com').
if (hostLabels.length < 3) continue;
const remainder = hostLabels.slice(1).join(".");
if (remainder === wildcardSuffix) return true;
}
return false;
}
/** Open a TLS connection with SNI set to hostname and return its SAN dNSNames. Requires network. */
async function fetchSanForHostname(hostname, port = 443) {
const tls = await import("node:tls");
return new Promise((resolve, reject) => {
const socket = tls.connect({ host: hostname, port, servername: hostname, timeout: 10000 }, () => {
const cert = socket.getPeerCertificate();
const raw = cert.subjectaltname || "";
const names = raw
.split(", ")
.filter((entry) => entry.startsWith("DNS:"))
.map((entry) => entry.slice(4));
socket.end();
resolve(names);
});
socket.on("error", reject);
socket.on("timeout", () => {
socket.destroy();
reject(new Error(`TLS connection to ${hostname}:${port} timed out`));
});
});
}
/** Read the current hosts array off a Cloudflare Advanced Certificate pack. */
async function getCertPackHosts() {
const url = `${CF_API}/zones/${CLOUDFLARE_ZONE_ID}/ssl/certificate_packs/${CLOUDFLARE_CERT_PACK_ID}`;
const res = await fetch(url, { headers: { Authorization: `Bearer ${CLOUDFLARE_API_TOKEN}` } });
if (!res.ok) throw new Error(`Cloudflare cert pack read failed: ${res.status}`);
const body = await res.json();
return body.result.hosts || [];
}
/** Add the missing hostname as a SAN by patching the Cloudflare certificate pack. */
async function addHostnameToCertPack(hostname) {
if (DRY_RUN) {
console.log(`[dry run] would add ${hostname} to certificate pack ${CLOUDFLARE_CERT_PACK_ID}`);
return;
}
const hosts = await getCertPackHosts();
const nextHosts = hosts.includes(hostname) ? hosts : [...hosts, hostname];
const url = `${CF_API}/zones/${CLOUDFLARE_ZONE_ID}/ssl/certificate_packs/${CLOUDFLARE_CERT_PACK_ID}`;
const res = await fetch(url, {
method: "PATCH",
headers: {
Authorization: `Bearer ${CLOUDFLARE_API_TOKEN}`,
"Content-Type": "application/json",
},
body: JSON.stringify({ hosts: nextHosts }),
});
if (!res.ok) throw new Error(`Cloudflare cert pack update failed: ${res.status}`);
console.log(`Requested certificate pack update to include ${hostname}`);
}
async function run() {
const hostname = DNS_DOMAIN;
const sanNames = await fetchSanForHostname(hostname);
console.log(`SAN entries served for ${hostname}: ${sanNames.join(", ")}`);
if (sanCoversHostname(hostname, sanNames)) {
console.log(`Nothing to repair. ${hostname} is already covered.`);
return;
}
console.warn(`Hostname ${hostname} is missing from the served certificate's SAN list.`);
if (!(CLOUDFLARE_API_TOKEN && CLOUDFLARE_ZONE_ID && CLOUDFLARE_CERT_PACK_ID)) {
console.warn("Mismatch found but no Cloudflare certificate pack credentials set. Skipping repair.");
return;
}
await addHostnameToCertPack(hostname);
}
if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) {
run().catch((err) => {
console.error(err);
process.exit(1);
});
}
Add a test
The SAN matching rule is the part worth testing, because it decides whether the script thinks a hostname is safe. It takes a plain string and a plain list, no network, no TLS handshake, so the tests run in milliseconds and cover the RFC 6125 wildcard edge cases directly.
from tls_san_hostname_mismatch import san_covers_hostname
def test_exact_match():
assert san_covers_hostname("example.com", ["example.com", "www.example.com"]) is True
def test_case_insensitive_exact_match():
assert san_covers_hostname("APP.example.com", ["app.example.com"]) is True
def test_missing_hostname_returns_false():
assert san_covers_hostname("app.example.com", ["example.com", "www.example.com"]) is False
def test_wildcard_matches_one_label_subdomain():
assert san_covers_hostname("app.example.com", ["*.example.com"]) is True
def test_wildcard_does_not_match_two_label_subdomain():
assert san_covers_hostname("a.b.example.com", ["*.example.com"]) is False
def test_wildcard_does_not_match_bare_apex():
assert san_covers_hostname("example.com", ["*.example.com"]) is False
import { test } from "node:test";
import assert from "node:assert/strict";
import { sanCoversHostname } from "./tls-san-hostname-mismatch.js";
test("exact match", () => {
assert.equal(sanCoversHostname("example.com", ["example.com", "www.example.com"]), true);
});
test("case insensitive exact match", () => {
assert.equal(sanCoversHostname("APP.example.com", ["app.example.com"]), true);
});
test("missing hostname returns false", () => {
assert.equal(sanCoversHostname("app.example.com", ["example.com", "www.example.com"]), false);
});
test("wildcard matches one label subdomain", () => {
assert.equal(sanCoversHostname("app.example.com", ["*.example.com"]), true);
});
test("wildcard does not match two label subdomain", () => {
assert.equal(sanCoversHostname("a.b.example.com", ["*.example.com"]), false);
});
test("wildcard does not match bare apex", () => {
assert.equal(sanCoversHostname("example.com", ["*.example.com"]), false);
});
Case studies
The API subdomain nobody told the certificate about
A team stood up api.example.com behind the same load balancer as the main site, expecting the existing certificate to just work. It did not, because that certificate's SAN list only ever listed example.com and www.example.com. Every mobile client calling the API failed its TLS handshake on day one.
Reissuing the certificate with --expand and the new hostname added took minutes once the mismatch was confirmed with openssl, and the fix shipped without touching a single line of application code.
The custom domain that got someone else's certificate
A customer pointed their own domain at a multi-tenant CDN edge but never finished the custom hostname setup. The edge kept answering with its default certificate, whose SAN list obviously did not include the customer's domain, so every visitor saw a hostname mismatch warning.
Enabling Total TLS for that hostname triggered automatic issuance of a certificate scoped to the exact domain, and the warning disappeared once the new cert pack reported active.
Every hostname a service actually answers on has a spot in some certificate's SAN list, either by exact name or by a wildcard that actually reaches it. Adding a new hostname is a two step habit: point DNS at the right place, then make sure a certificate names it. Skipping the second step is the entire failure mode this guide covers.
FAQ
Why do I get a certificate hostname mismatch when the site has a valid certificate?
Browsers only check the Subject Alternative Name (SAN) list on the certificate, never the old Common Name field. If the hostname you connected to is not listed as a SAN entry, exactly or through a wildcard, the handshake fails even though the certificate itself is otherwise valid and unexpired.
Does a wildcard certificate cover every subdomain?
No. A wildcard like *.example.com only covers one label depth, so it matches app.example.com but not a.b.example.com and not the bare apex example.com. Each of those needs its own SAN entry or its own certificate.
How do I add a new hostname to an existing certificate without losing the old ones?
List every existing SAN plus the new one when you reissue, or use a flag built for it, such as certbot's --expand. On Cloudflare, enable Total TLS for automatic per-hostname certificates or add the host to an Advanced Certificate's hosts array. Dropping an old SAN by accident breaks whichever hostname used to rely on it.
Related field notes
Citations
On the problem:
- RFC 6125: Representation and Verification of Domain-Based Application Service Identity. datatracker.ietf.org/doc/html/rfc6125
- RFC 9525: Service Identity in TLS (obsoletes RFC 6125). datatracker.ietf.org/doc/rfc9525
- DigiCert: Name Mismatch in Web Browser. knowledge.digicert.com/solution/name-mismatch-in-web-browser
On the solution:
- Certbot User Guide: expanding certificates with additional domains (-d / --expand). eff-certbot.readthedocs.io/en/stable/using.html
- Cloudflare SSL/TLS docs: Total TLS (per-hostname certificate issuance). developers.cloudflare.com/ssl/edge-certificates/additional-options/total-tls
- Cloudflare SSL/TLS docs: Advanced Certificate Manager (custom SAN lists, up to 50 hosts). developers.cloudflare.com/ssl/edge-certificates/advanced-certificate-manager
Stuck on a tricky one?
If you have a DNS, email deliverability, or certificate problem you would rather hand off, this is the kind of work I do. Message me and we can work through it together.
Did this fix your certificate?
If this saved you a broken launch or a pile of confused support tickets, you can buy me a coffee. It is the best way to keep these field notes free and growing.
Buy me a coffee on Ko-fi