Objective
A hostname mismatch is the one TLS failure that is entirely the certificateβs fault and entirely unrelated to trust. The chain is perfect. The dates are fine. The signature verifies. The client still refuses, because the identity it asked for is not among the identities the certificate asserts, and no amount of trust-store work will change that.
You will reproduce it deliberately against a service you have already deployed correctly, so that every other variable is known good. Then you will reproduce the same verdict against a file with no network involved at all, which is the technique that lets you answer βwill this certificate work for that nameβ before a change window rather than during one.
The middle of the lab settles a question that still causes arguments: whether the Common Name in the subject can stand in for a missing subject alternative name. You will issue a certificate whose Common Name matches the host being requested and whose subject alternative name does not, and watch it be rejected. The lab ends with the repair, and with an argument about which names belong on a certificate and which are somebody elseβs bug.
Architecture
One nginx container serves a correctly chained certificate for app.lab.example on port 443,
published to the host on 8448. The certificate is the constant. What varies is the name the client
asks for, supplied through --resolve for curl and through -verify_hostname for the offline
check, so that the same bytes are judged repeatedly against different questions.
flowchart TD
C["certificate\nSAN: app.lab.example, www.app.lab.example"] --> Q{"name the client\nasked for"}
Q -- "app.lab.example" --> OK1["matches a SAN entry\nverification passes"]
Q -- "www.app.lab.example" --> OK2["matches a SAN entry\nverification passes"]
Q -- "wrong.lab.example" --> BAD["no SAN entry matches\ncurl 60, verify error 62"]
Q -- "a name only in the Common Name" --> BAD
The diagram makes the asymmetry explicit. Chain validation asks a question about issuers and signatures and produces one answer for a certificate. Name matching asks a question about the clientβs intent, so the same certificate has as many answers as there are names anyone might use to reach it. A certificate is not correct or incorrect on its own; it is correct for a set of names, and that set is written in one extension.
Requirements
- OpenSSL 3.5.x, providing
openssl verifywith-verify_hostname, andopenssl x509 -ext. - Docker with permission to run containers and publish a port. The lab pulls
nginx:1.29-alpine. - curl built against OpenSSL.
- Host TCP port 8448 free.
- No out-of-band access requirement. This lab does not touch SSH, the firewall, the primary
interface, or
/etc/fstab. It writes nothing to/etc/hostsand nothing to the system trust store; names are mapped per-call with--resolve.
Labs 4 and 5 built a two-tier certificate authority and issued the leaf this lab uses, and lab 6
deployed it. If you still have root.crt, srv-ca.crt, srv-ca.key, app.key and app.crt,
copy them into $LAB/pki and skip the certificate authority half of Task 2. Otherwise Task 2
rebuilds an equivalent set.
Scenario
Overnight, a second DNS name was pointed at a service that has been running happily behind TLS for months. Clients using the original name are unaffected. Clients using the new name fail immediately with a certificate error, and the first three people to look at it check the expiry date, check the chain, and find nothing wrong with either.
Separately, a stale alias in a load balancer health check is still sending probes to the service under a name nobody has used for a year. It produces the identical error. One of these two names belongs on the certificate and the other belongs in a deletion ticket, and the lab is as much about telling them apart as it is about the mechanism.
Tasks
Task 1 β Prepare the lab directory and record the starting state
LAB="$HOME/rbpki-lab-08"
rm -rf "$LAB"
mkdir -p "$LAB/pki" "$LAB/site" "$LAB/html"
cd "$LAB"
{
echo "--- containers before the lab"
docker ps -a --format '{{.Names}}'
} > "$LAB/state.pre-lab"
docker rm -f rbpki-web-08 2>/dev/null || true
echo 'lab ok' > "$LAB/html/index.html"
cat "$LAB/state.pre-lab"
The single-line page body is the success signal for every client call in this lab. Name matching
happens before any byte of the response is produced, so receiving lab ok proves the client
accepted the identity as well as the chain.
Task 2 β Build the certificate authority and a correctly named leaf
Skip the first half if you copied the certificate authority from lab 5.
LAB="$HOME/rbpki-lab-08"
cd "$LAB/pki"
openssl genpkey -algorithm RSA -pkeyopt rsa_keygen_bits:4096 -out root.key
openssl req -x509 -new -key root.key -sha256 -days 3650 \
-subj "/O=RunBook Academy Lab/CN=RunBook Lab Root CA" \
-addext "basicConstraints=critical,CA:TRUE,pathlen:1" \
-addext "keyUsage=critical,keyCertSign,cRLSign" -out root.crt
openssl genpkey -algorithm RSA -pkeyopt rsa_keygen_bits:3072 -out srv-ca.key
openssl req -new -key srv-ca.key -sha256 \
-subj "/O=RunBook Academy Lab/CN=RunBook Lab Server Issuing CA" -out srv-ca.csr
cat > srv-ca.ext <<'EOF'
basicConstraints=critical,CA:TRUE,pathlen:0
keyUsage=critical,keyCertSign,cRLSign
subjectKeyIdentifier=hash
authorityKeyIdentifier=keyid:always
EOF
openssl x509 -req -in srv-ca.csr -CA root.crt -CAkey root.key -CAcreateserial \
-sha256 -days 1825 -extfile srv-ca.ext -out srv-ca.crt
# The leaf, naming exactly two hosts and no more.
openssl genpkey -algorithm EC -pkeyopt ec_paramgen_curve:P-256 -out app.key
openssl req -new -key app.key -sha256 -subj "/CN=app.lab.example" -out app.csr
cat > app.ext <<'EOF'
basicConstraints=critical,CA:FALSE
keyUsage=critical,digitalSignature,keyEncipherment
extendedKeyUsage=serverAuth
subjectAltName=DNS:app.lab.example,DNS:www.app.lab.example
subjectKeyIdentifier=hash
authorityKeyIdentifier=keyid:always
EOF
openssl x509 -req -in app.csr -CA srv-ca.crt -CAkey srv-ca.key -CAcreateserial \
-sha256 -days 90 -extfile app.ext -out app.crt
cat app.crt srv-ca.crt > fullchain.pem
chmod 600 root.key srv-ca.key app.key
openssl x509 -in app.crt -noout -ext subjectAltName | tee "$LAB/name-inventory.txt"
$ openssl x509 -in app.crt -noout -ext subjectAltNameX509v3 Subject Alternative Name:
DNS:app.lab.example, DNS:www.app.lab.exampleIllustrative output
Two names, and they are the complete answer to βwhat will this certificate work forβ. The subject
line says CN=app.lab.example, which happens to duplicate the first entry, and you are about to
establish that the duplication is decoration rather than function.
Task 3 β Deploy it and confirm the names that do work
LAB="$HOME/rbpki-lab-08"
cat > "$LAB/site/default.conf" <<'EOF'
server {
listen 443 ssl;
server_name app.lab.example;
ssl_certificate /etc/nginx/certs/fullchain.pem;
ssl_certificate_key /etc/nginx/certs/app.key;
ssl_protocols TLSv1.2 TLSv1.3;
root /usr/share/nginx/html;
index index.html;
}
EOF
docker run -d --name rbpki-web-08 -p 8448:443 \
-v "$LAB/site:/etc/nginx/conf.d:ro" \
-v "$LAB/pki:/etc/nginx/certs:ro" \
-v "$LAB/html:/usr/share/nginx/html:ro" \
nginx:1.29-alpine
sleep 2
cd "$LAB/pki"
curl -sS --resolve app.lab.example:8448:127.0.0.1 --cacert root.crt \
https://app.lab.example:8448/
curl -sS --resolve www.app.lab.example:8448:127.0.0.1 --cacert root.crt \
https://www.app.lab.example:8448/
Both calls return lab ok. The second one is worth pausing on: www.app.lab.example is nowhere in
the subject, appears only as the second entry in the subject alternative name, and is accepted
without hesitation. Whatever is doing the matching is reading that extension.
Task 4 β Reproduce the mismatch from a client
LAB="$HOME/rbpki-lab-08"
cd "$LAB/pki"
# Capture the message and the exit status separately, because the
# status is the same for several unrelated TLS failures.
curl -sS --resolve wrong.lab.example:8448:127.0.0.1 --cacert root.crt \
https://wrong.lab.example:8448/ > "$LAB/mismatch.out" 2>&1
STATUS=$?
head -1 "$LAB/mismatch.out"
echo "curl exit status was: $STATUS"
$ curl -sS --resolve wrong.lab.example:8448:127.0.0.1 --cacert root.crt https://wrong.lab.example:8448/curl: (60) SSL: no alternative certificate subject name matches target hostname 'wrong.lab.example'Illustrative output
This error message is unusually honest. It names the extension it consulted, it names the host it was looking for, and it tells you the search came back empty. Compare it with the chain failures in lab 7, which reported verify result 20 and named nothing at all. When you see this string, you already know the whole diagnosis: the certificate is fine, the trust is fine, and the set of names is too small for the request that was made.
Note also the exit status. curl returns 60 for both failures, so a script that only checks the exit code cannot tell a mismatch from a broken chain. The message is where the distinction lives, which is a good argument for capturing stderr in monitoring rather than discarding it.
Task 5 β Reproduce the same verdict offline, against the file
LAB="$HOME/rbpki-lab-08"
cd "$LAB/pki"
# The chain question, asked on its own. No hostname involved.
openssl verify -CAfile root.crt -untrusted srv-ca.crt app.crt
# The name question, asked for a name that is present.
openssl verify -CAfile root.crt -untrusted srv-ca.crt \
-verify_hostname app.lab.example app.crt
# The name question, asked for a name that is not.
openssl verify -CAfile root.crt -untrusted srv-ca.crt \
-verify_hostname wrong.lab.example app.crt
$ openssl verify -CAfile root.crt -untrusted srv-ca.crt -verify_hostname app.lab.example app.crt; openssl verify -CAfile root.crt -untrusted srv-ca.crt -verify_hostname wrong.lab.example app.crtapp.crt: OK
error 62 at 0 depth lookup: hostname mismatchIllustrative output
The first openssl verify in the block, with no -verify_hostname at all, prints app.crt: OK.
That is the trap in a single line: a certificate can pass verification completely and still be
useless for the host you intend to serve, because by default the tool never asked about a name.
Adding -verify_hostname turns a partial check into the check a client actually performs.
Error 62 at depth 0 is the offline twin of the curl message. Depth 0 is the leaf, which is the only certificate whose names anyone cares about; intermediates and roots carry no service identity.
Task 6 β Prove the Common Name is not consulted
LAB="$HOME/rbpki-lab-08"
cd "$LAB/pki"
# A certificate whose SUBJECT says one thing and whose SAN says another.
openssl genpkey -algorithm EC -pkeyopt ec_paramgen_curve:P-256 -out cn.key
openssl req -new -key cn.key -sha256 -subj "/CN=cnonly.lab.example" -out cn.csr
cat > cn.ext <<'EOF'
basicConstraints=critical,CA:FALSE
keyUsage=critical,digitalSignature,keyEncipherment
extendedKeyUsage=serverAuth
subjectAltName=DNS:app.lab.example
subjectKeyIdentifier=hash
authorityKeyIdentifier=keyid:always
EOF
openssl x509 -req -in cn.csr -CA srv-ca.crt -CAkey srv-ca.key -CAcreateserial \
-sha256 -days 90 -extfile cn.ext -out cn.crt
openssl x509 -in cn.crt -noout -subject -ext subjectAltName
# Ask for the name that is in the Common Name. It is not consulted.
openssl verify -CAfile root.crt -untrusted srv-ca.crt \
-verify_hostname cnonly.lab.example cn.crt
# Ask for the name that is in the subject alternative name.
openssl verify -CAfile root.crt -untrusted srv-ca.crt \
-verify_hostname app.lab.example cn.crt
The first verification fails with error 62 at 0 depth lookup: hostname mismatch even though the
requested host is an exact, character-for-character match for the certificateβs Common Name. The
second succeeds for a name that appears nowhere in the subject. Once a subject alternative name
extension is present, it is the only place identity is read from, and everything in the subject is
ignored for this purpose.
Task 7 β Repair the certificate for the name that deserves it
LAB="$HOME/rbpki-lab-08"
cd "$LAB/pki"
# api.lab.example is a legitimate new name for this service.
# wrong.lab.example is a stale alias and is deliberately NOT added.
cat > app.ext <<'EOF'
basicConstraints=critical,CA:FALSE
keyUsage=critical,digitalSignature,keyEncipherment
extendedKeyUsage=serverAuth
subjectAltName=DNS:app.lab.example,DNS:www.app.lab.example,DNS:api.lab.example
subjectKeyIdentifier=hash
authorityKeyIdentifier=keyid:always
EOF
openssl x509 -req -in app.csr -CA srv-ca.crt -CAkey srv-ca.key -CAcreateserial \
-sha256 -days 90 -extfile app.ext -out app.crt
cat app.crt srv-ca.crt > fullchain.pem
docker exec rbpki-web-08 nginx -s reload
sleep 2
curl -sS --resolve api.lab.example:8448:127.0.0.1 --cacert root.crt \
https://api.lab.example:8448/
curl -sS --resolve wrong.lab.example:8448:127.0.0.1 --cacert root.crt \
https://wrong.lab.example:8448/ 2>&1 | head -1
openssl x509 -in app.crt -noout -ext subjectAltName | tee "$LAB/name-inventory.txt"
The new name works and the stale one still fails, which is the correct outcome for both. Re-issuing with the same key and the same request is deliberate here: the identity changed, the key did not, so nothing on the server needed rotating beyond the certificate file and a reload.
The judgement call is the interesting part. wrong.lab.example produces exactly the same error as
api.lab.example did, and adding it would have silenced the alert in one line. It would also have
made a stale health-check configuration permanent, hidden the fact that something is still probing
a name nobody owns, and enlarged the set of identities an attacker who obtains this key could
impersonate. A certificate is not the place to absorb someone elseβs misconfiguration.
Task 8 β Capture the deliverables and a name check
LAB="$HOME/rbpki-lab-08"
cd "$LAB/pki"
{
echo "=== curl, name not on the certificate"
curl -sS --resolve wrong.lab.example:8448:127.0.0.1 --cacert root.crt \
https://wrong.lab.example:8448/ 2>&1 | head -1
echo "=== openssl verify, same name, no network"
openssl verify -CAfile root.crt -untrusted srv-ca.crt \
-verify_hostname wrong.lab.example app.crt 2>&1 || true
} > "$LAB/mismatch-evidence.txt"
{
echo "=== subject and SAN of the Common-Name-only test certificate"
openssl x509 -in cn.crt -noout -subject -ext subjectAltName
echo "=== verified for the name in the Common Name"
openssl verify -CAfile root.crt -untrusted srv-ca.crt \
-verify_hostname cnonly.lab.example cn.crt 2>&1 || true
echo "=== verified for the name in the SAN"
openssl verify -CAfile root.crt -untrusted srv-ca.crt \
-verify_hostname app.lab.example cn.crt
} > "$LAB/cn-not-consulted.txt"
cat > "$LAB/check-served-names.sh" <<'EOF'
#!/bin/sh
# Fail when a service does not assert the name it is reached by.
# Usage: check-served-names.sh HOSTPORT NAME CAFILE
set -eu
ENDPOINT="$1"
NAME="$2"
CAFILE="$3"
if curl -sS --resolve "$NAME:${ENDPOINT##*:}:${ENDPOINT%%:*}" \
--cacert "$CAFILE" "https://$NAME:${ENDPOINT##*:}/" >/dev/null 2>&1; then
echo "OK $NAME is asserted by the certificate served at $ENDPOINT"
else
echo "FAIL $NAME is not asserted by the certificate served at $ENDPOINT"
exit 1
fi
EOF
chmod +x "$LAB/check-served-names.sh"
"$LAB/check-served-names.sh" 127.0.0.1:8448 api.lab.example "$LAB/pki/root.crt"
ls -l "$LAB/name-inventory.txt" "$LAB/mismatch-evidence.txt" \
"$LAB/cn-not-consulted.txt" "$LAB/check-served-names.sh"
The check is written to be run once per name the service is meant to answer to, from a list held alongside the DNS records rather than alongside the certificate. That direction matters: reading names out of the certificate and checking they work tells you nothing about the name somebody added last night.
Validation
openssl x509 -in app.crt -noout -ext subjectAltNamelistsDNS:app.lab.example,DNS:www.app.lab.exampleand, after Task 7,DNS:api.lab.example.- curl against
app.lab.exampleandwww.app.lab.examplereturnslab okand exits 0. - curl against
wrong.lab.exampleexits 60 and printsSSL: no alternative certificate subject name matches target hostname. A pass here is the failure appearing. openssl verify -CAfile root.crt -untrusted srv-ca.crt app.crtprintsapp.crt: OKwith no hostname supplied, and the same command with-verify_hostname wrong.lab.exampleprintserror 62 at 0 depth lookup: hostname mismatchand exits 2.openssl verify ... -verify_hostname cnonly.lab.example cn.crtexits 2 with error 62, and the same command with-verify_hostname app.lab.exampleprints a result of OK and exits 0.- After Task 7, curl against
api.lab.examplereturnslab ok, and curl againstwrong.lab.examplestill exits 60. check-served-names.sh 127.0.0.1:8448 api.lab.exampleexits 0, and the same script exits 1 forwrong.lab.example.- The four deliverables exist and are non-empty.
A failed validation usually means the reload did not happen. If api.lab.example still fails after
Task 7, check that fullchain.pem was rebuilt from the new app.crt and that nginx -s reload
ran without error. If cnonly.lab.example unexpectedly verifies, the extension file was not picked
up and cn.crt has no subject alternative name at all, which is the legacy case the Task 6 callout
describes rather than a contradiction of it.
Expected Outcome
$HOME/rbpki-lab-08/
βββ html/
β βββ index.html
βββ pki/
β βββ app.crt
β βββ app.ext
β βββ app.key
β βββ cn.crt
β βββ cn.key
β βββ fullchain.pem
β βββ root.crt
β βββ srv-ca.crt
β βββ srv-ca.key
βββ site/
β βββ default.conf
βββ check-served-names.sh
βββ cn-not-consulted.txt
βββ mismatch-evidence.txt
βββ mismatch.out
βββ name-inventory.txt
βββ state.pre-lab
You can now decide, without touching a server, whether a certificate will work for a given hostname, and you can tell a name failure apart from a trust failure from the error text alone. More usefully, you can argue about which names belong on a certificate with evidence rather than by reflex.
Troubleshooting
curl reports verify result 20 rather than a name error. You are hitting a chain problem before the name is ever considered. Fix that first; hostname matching is evaluated against a certificate the client has already decided it can trust.
--resolve appears to be ignored. The port in the --resolve argument must match the port in
the URL exactly. --resolve app.lab.example:443:127.0.0.1 does nothing for a request to port 8448.
openssl verify accepts every name you try. -verify_hostname was omitted or misspelled.
Without it the tool performs no name matching at all and reports OK for a certificate that names
nothing you asked about.
The certificate reissued in Task 7 is rejected by nginx after the reload. fullchain.pem was
not rebuilt, so the file still contains the previous leaf. Re-run the cat that concatenates
app.crt and srv-ca.crt, then reload again.
A wildcard certificate fails for a name you expected it to cover. A wildcard matches exactly one
label. *.lab.example does not cover api.internal.lab.example, and no client will treat it as
though it does.
Cleanup
LAB="$HOME/rbpki-lab-08"
# 1. Stop and forget the service.
docker rm -f rbpki-web-08 2>/dev/null || true
# 2. Compare against the inventory recorded in Task 1.
cat "$LAB/state.pre-lab"
echo "--- containers now"
docker ps -a --format '{{.Names}}'
# 3. Remove the lab directory, keys included.
rm -rf "$LAB"
# 4. Assert the host is as you found it.
docker ps -a --format '{{.Names}}' | grep -c '^rbpki-web-08$' || echo "container gone"
grep -c 'lab.example' /etc/hosts || echo "no lab names in /etc/hosts, as expected"
test -d "$LAB" && echo "LAB DIRECTORY STILL PRESENT" || echo "lab directory gone"
The container inventories must differ only by the removal of rbpki-web-08. The /etc/hosts check
is there to prove a negative: this lab mapped every name with --resolve, so a lab name appearing
in that file means an earlier session left something behind and it should be removed by hand. All
three assertions must report the resource absent.
Production notes
- Hostname coverage is a property of your DNS estate, not of your certificate inventory. Audit from the list of names in use towards the certificates, because the reverse direction cannot detect the name that was added last night.
- Every additional name on a certificate widens what a stolen key can impersonate. Large multi-name certificates are convenient and concentrate risk; separate certificates per service cost more to manage and fail smaller.
- A wildcard removes the mismatch class entirely for one level of subdomain and creates a key whose compromise affects every host under it. That trade is a decision to record, not a default.
- Certificate lifetimes for publicly trusted TLS certificates are shrinking on a published schedule, so any process that requires a human to remember which names go on a renewal will fail sooner each year. Keep the name list in the same repository as the DNS records.
What You Learned
- Name matching is a separate question from trust. A certificate can verify perfectly and still be refused, and the error text tells you which question failed.
- Identity lives in the subject alternative name. RFC 9525 removed the Common Name fallback, and a Common Name that duplicates a subject alternative name entry is decoration.
- A subject alternative name that exists silences the subject entirely. The Task 6 certificate matched a host that appears nowhere in its subject and refused the host named in its Common Name.
-verify_hostnameturnsopenssl verifyinto the check a client performs. Without it the tool answers a narrower question and cheerfully reports OK.- curl exits 60 for several unrelated faults. The exit code is not a diagnosis; the message is.
- Not every mismatch deserves a certificate change. Adding a stale alias to the subject alternative name silences the alarm and preserves the bug that caused it.