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.

Python and Node.js Cloudflare API Safe by default (dry run)
Padlocks
Photo by FlyD on Unsplash
The short answer

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.

Client requests app.example.com (SNI) Server presents cert SAN: www, example.com app not in SAN handshake fails Cert warning Same client requests www.example.com (SNI) Same certificate SAN: www, example.com www is listed, connects fine this hostname was never the problem
The exact same certificate is fine for www.example.com and broken for app.example.com, because the SAN list was written for one set of names and the new one was never added.

Why it happens

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.

The key insight

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.

Check SAN vs hostnames in use Missing hostname flagged, e.g. app.* Reissue with full SAN old names + new one Deploy and reload nginx, ALB, or edge All names covered no more mismatch Handshake succeeds for every hostname
The fix loops back: check every hostname against the SAN list, reissue with the full set of names, deploy it, and the same check now passes for the hostname that used to fail.

How to fix it

1

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.

Terminal
confirm-endpoint.sh
dig +short A example.com
dig +short CNAME app.example.com
# confirm both names resolve to the endpoint you expect before touching any certificate
2

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.

Terminal
check-san.sh
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
3

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.

Terminal
reissue-certbot.sh
# 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
4

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.

Terminal
cloudflare-cert-pack.sh
# 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"
5

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.

List every hostname, not just the new one

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.

Terminal
verify.sh
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.

tls_san_hostname_mismatch.py
"""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()
tls-san-hostname-mismatch.js
/**
 * 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.

test_san_covers.py
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
san-covers.test.js
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

New subdomain

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.

CDN default cert

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.

What good looks like

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:

  1. RFC 6125: Representation and Verification of Domain-Based Application Service Identity. datatracker.ietf.org/doc/html/rfc6125
  2. RFC 9525: Service Identity in TLS (obsoletes RFC 6125). datatracker.ietf.org/doc/rfc9525
  3. DigiCert: Name Mismatch in Web Browser. knowledge.digicert.com/solution/name-mismatch-in-web-browser

On the solution:

  1. Certbot User Guide: expanding certificates with additional domains (-d / --expand). eff-certbot.readthedocs.io/en/stable/using.html
  2. Cloudflare SSL/TLS docs: Total TLS (per-hostname certificate issuance). developers.cloudflare.com/ssl/edge-certificates/additional-options/total-tls
  3. 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.

Contact me on LinkedIn

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

Back to all DNS and Domains field notes