Skip to main content
RunBook Academy

← All labs in Secrets, PKI & Certificates

Lab Β· intermediate Β· ~40 min

Lab 8: Diagnose a hostname mismatch

C Β· SimulationB Β· Nested virtualisation

Objectives

  • Reproduce a hostname mismatch on a running service and separate it from every chain-related failure
  • Use openssl verify -verify_hostname to reproduce the same verdict against a file, with no network involved
  • Demonstrate that a name present only in the Common Name is not matched once the certificate carries a subject alternative name
  • Repair the certificate by naming every hostname the service is legitimately reached by, and refuse to widen it for the names that are mistakes

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 verify with -verify_hostname, and openssl 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/hosts and 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"
Read-only / Safehost - the names this certificate actually asserts
$ openssl x509 -in app.crt -noout -ext subjectAltName
X509v3 Subject Alternative Name:
  DNS:app.lab.example, DNS:www.app.lab.example

Illustrative 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"
Read-only / Safehost - the same certificate, requested under a name it does not carry
$ 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
Read-only / Safehost - the same file, judged for two different names
$ 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.crt
app.crt: OK
error 62 at 0 depth lookup: hostname mismatch

Illustrative 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 subjectAltName lists DNS:app.lab.example, DNS:www.app.lab.example and, after Task 7, DNS:api.lab.example.
  • curl against app.lab.example and www.app.lab.example returns lab ok and exits 0.
  • curl against wrong.lab.example exits 60 and prints SSL: 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.crt prints app.crt: OK with no hostname supplied, and the same command with -verify_hostname wrong.lab.example prints error 62 at 0 depth lookup: hostname mismatch and exits 2.
  • openssl verify ... -verify_hostname cnonly.lab.example cn.crt exits 2 with error 62, and the same command with -verify_hostname app.lab.example prints a result of OK and exits 0.
  • After Task 7, curl against api.lab.example returns lab ok, and curl against wrong.lab.example still exits 60.
  • check-served-names.sh 127.0.0.1:8448 api.lab.example exits 0, and the same script exits 1 for wrong.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_hostname turns openssl verify into 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.

Deliverables

  • Β· name-inventory.txt - every DNS name the certificate asserts, read from the certificate rather than from the request
  • Β· mismatch-evidence.txt - the curl and openssl verify output for a name the certificate does not carry
  • Β· cn-not-consulted.txt - the verification result for a certificate whose Common Name matches the requested host and whose subject alternative name does not
  • Β· check-served-names.sh - a check that fails when a service is reachable under a name its certificate does not assert

Verification status

Last reviewed
2026-08-26
Executed end to end
not yet run on hardware

The commands and configuration here have been reviewed against the verified software versions, but nobody has run this lab start to finish on a system meeting its prerequisites. Treat the Expected Outcome as the intended result rather than an observed one, and keep the Cleanup section to hand.