Module 07 — Certificate Validation: What a Client Actually Checks

Updated 21 August 2026

Module 07 · Certificate Validation: What a Client Actually Checks

Module 06 delivered a certificate and a chain. This module is the moment immediately after: the client decides whether to accept it. You will reproduce every common failure on purpose and see the exact error each one produces in curl, Python, Go, Node and OpenSSL — because recognising an error on sight is most of what TLS troubleshooting actually is.

🧠 concept → 🧪 exercise → ✅ expected result (hidden) → 🎯 interview questions (answers hidden)

Prerequisite: Modules 01–06. You need certificate fields from Module 03, chain validation and verify codes from Module 05, and the handshake from Module 06.


The picture to hold in your head for this whole module — the officer's checklist.

The passport is on the desk. The officer now works through a list, and stops at the first thing that fails:

  1. Is this document genuine — can I trace its stamp back to a government on my list?
  2. Is it in date?
  3. Is it the right person standing here?
  4. Is it the right kind of document for what you are asking to do?
  5. Has it been cancelled since it was issued?

Five independent gates. Passing four and failing one is a refusal. And — this is the part that catches people — different officers work through the list in different orders, so two officers can refuse the same traveller for two different stated reasons.

Part A · The checks, in order

A1 · What a client actually verifies

#GateWhat it rejects
1Build a pathNo route from this certificate to a trusted anchor — missing intermediate, or unknown CA
2Verify every signatureA forged or corrupted certificate anywhere in the chain
3Check every validity periodExpired or not-yet-valid — at any depth, not just the leaf
4Enforce CA constraintsCA:FALSE used as an issuer, pathlen exceeded, name constraints violated
5Check the purposeKey Usage or Extended Key Usage that does not permit TLS server authentication
6Match the hostnameA perfectly valid certificate belonging to somebody else
7Check revocationA certificate withdrawn since issuance (Module 09) — and often skipped entirely
Three things about this list that matter more than the list itself.

Gates 1–5 are about the chain. Gate 6 is not. Hostname matching is a completely separate operation performed on the leaf. That is why openssl verify says OK for a certificate belonging to a different site unless you add -verify_hostname — you have run gates 1–5 and skipped 6.

Gate 3 applies at every depth. An expired intermediate fails the whole chain even though your own certificate has a year left (Module 05, D3).

Gate 7 is the weak one. Most clients either skip revocation or fail open when the responder is unreachable. Module 09 is entirely about why.

Build the failure lab. Everything in this module runs offline against certificates you generate yourself. This one script creates all of them.
bash
mkdir -p ~/tls-lab/m07 && cd ~/tls-lab/m07
umask 077

# --- a CA, and a second unrelated CA ---
openssl genpkey -algorithm RSA -pkeyopt rsa_keygen_bits:2048 -out ca.key
openssl req -x509 -key ca.key -out ca.crt -days 3650 -subj "/CN=Test Lab Root CA" \
  -addext "basicConstraints=critical,CA:TRUE" -addext "keyUsage=critical,keyCertSign,cRLSign"
openssl genpkey -algorithm RSA -pkeyopt rsa_keygen_bits:2048 -out other-ca.key
openssl req -x509 -key other-ca.key -out other-ca.crt -days 3650 -subj "/CN=Some Other CA" \
  -addext "basicConstraints=critical,CA:TRUE" -addext "keyUsage=critical,keyCertSign"

# --- one server key, reused everywhere ---
openssl genpkey -algorithm RSA -pkeyopt rsa_keygen_bits:2048 -out srv.key

mkext() { printf 'basicConstraints=critical,CA:FALSE\nkeyUsage=critical,digitalSignature,keyEncipherment\nextendedKeyUsage=%s\nsubjectAltName=%s\n' "$2" "$1" > ext.cnf; }
sign()  { mkext "$2" "$3"; openssl req -new -key srv.key -out t.csr -subj "/CN=$1"
          openssl x509 -req -in t.csr -CA ca.crt -CAkey ca.key -out "$1.crt" -days 90 -extfile ext.cnf; }

sign good.test       "DNS:good.test,IP:127.0.0.1"        serverAuth
sign clientonly.test "DNS:clientonly.test,IP:127.0.0.1"  clientAuth

# --- signed by the OTHER CA: untrusted root ---
mkext "DNS:untrusted.test,IP:127.0.0.1" serverAuth
openssl req -new -key srv.key -out t.csr -subj "/CN=untrusted.test"
openssl x509 -req -in t.csr -CA other-ca.crt -CAkey other-ca.key -out untrusted.test.crt -days 90 -extfile ext.cnf

# --- self-signed ---
openssl req -x509 -key srv.key -out selfsigned.test.crt -days 90 -subj "/CN=selfsigned.test" \
  -addext "subjectAltName=DNS:selfsigned.test,IP:127.0.0.1" \
  -addext "basicConstraints=critical,CA:FALSE" -addext "extendedKeyUsage=serverAuth"

# --- an intermediate, for the incomplete-chain case ---
openssl genpkey -algorithm RSA -pkeyopt rsa_keygen_bits:2048 -out int.key
openssl req -new -key int.key -out int.csr -subj "/CN=Test Lab Issuing CA"
printf 'basicConstraints=critical,CA:TRUE,pathlen:0\nkeyUsage=critical,keyCertSign,cRLSign\n' > i.cnf
openssl x509 -req -in int.csr -CA ca.crt -CAkey ca.key -out int.crt -days 1825 -extfile i.cnf
mkext "DNS:chain.test,IP:127.0.0.1" serverAuth
openssl req -new -key srv.key -out t.csr -subj "/CN=chain.test"
openssl x509 -req -in t.csr -CA int.crt -CAkey int.key -out chain.test.crt -days 90 -extfile ext.cnf

ls *.crt

Expired and not-yet-valid certificates need explicit dates, which openssl x509 -req cannot set on OpenSSL 3.0 — so use openssl ca, which can:

bash
mkdir -p db certs && touch db/index.txt && openssl rand -hex 8 > db/serial
cat > ca.cnf <<'EOF'
[ca]
default_ca=CA
[CA]
dir=.
database=$dir/db/index.txt
serial=$dir/db/serial
new_certs_dir=$dir/certs
certificate=$dir/ca.crt
private_key=$dir/ca.key
default_md=sha256
policy=pol
rand_serial=yes
unique_subject=no
copy_extensions=none
email_in_dn=no
[pol]
commonName=supplied
[srv]
basicConstraints=critical,CA:FALSE
keyUsage=critical,digitalSignature,keyEncipherment
extendedKeyUsage=serverAuth
subjectAltName=DNS:expired.test,DNS:future.test,IP:127.0.0.1
EOF

openssl req -new -key srv.key -out t.csr -subj "/CN=expired.test"
openssl ca -config ca.cnf -extensions srv -batch -notext -in t.csr -out expired.test.crt \
  -startdate 20240101000000Z -enddate 20240401000000Z

openssl req -new -key srv.key -out t.csr -subj "/CN=future.test"
openssl ca -config ca.cnf -extensions srv -batch -notext -in t.csr -out future.test.crt \
  -startdate 20300101000000Z -enddate 20310101000000Z

for f in good expired future; do printf '%-16s ' "$f"; openssl x509 -in $f.test.crt -noout -dates | tr '\n' ' '; echo; done

All output in this module was produced on OpenSSL 3.0.13, curl 8.x, Python 3.12, Go 1.2x and Node 22.

🧪 Exercise A1.1 — Confirm the lab is built and the dates are what you asked for
bash
cd ~/tls-lab/m07
ls *.crt
for f in good expired future; do printf '%-16s ' "$f"; openssl x509 -in $f.test.crt -noout -dates | tr '\n' ' '; echo; done
Expected result — click to reveal
plain text
ca.crt  chain.test.crt  clientonly.test.crt  expired.test.crt  future.test.crt
good.test.crt  int.crt  other-ca.crt  selfsigned.test.crt  untrusted.test.crt

good             notBefore=Aug 20 14:16:09 2026 GMT notAfter=Nov 18 14:16:09 2026 GMT
expired          notBefore=Jan  1 00:00:00 2024 GMT notAfter=Apr  1 00:00:00 2024 GMT
future           notBefore=Jan  1 00:00:00 2030 GMT notAfter=Jan  1 00:00:00 2031 GMT

What to read out of this.

  • Ten certificates, one private key. Every leaf shares srv.key, which keeps the lab simple and makes a useful point: none of these failures is about the key. They are all about what the certificate says and who vouched for it.
  • openssl x509 -req cannot set explicit dates on OpenSSL 3.0 — only -days, counted from now. -not_before and -not_after arrived later. openssl ca has always had -startdate and -enddate, which is why the expired and future certificates go through it.
  • That is a genuinely useful trick. Generating a deliberately expired certificate is the only honest way to test that your monitoring and your error handling work. Doing it by changing the system clock breaks other things; doing it with -enddate costs one flag.

A2 · Three gates people conflate

The analogy — three different doors, not one.

Is the document real? The officer traces the stamp to a government on the list. This is the chain.

Is it yours? They compare the photo with the face in front of them. This is the hostname.

Does it entitle you to this? A valid driving licence does not get you onto a flight. This is the purpose.

Three independent questions. A document can pass any two and fail the third. Treating them as one thing — "is the certificate valid?" — is why people get stuck: they fix the chain and are baffled that it still fails.

🧪 Exercise A2.1 — Watch the same certificate pass one gate and fail another
bash
cd ~/tls-lab/m07

echo "=== chain only: does it trace to our CA? ==="
openssl verify -CAfile ca.crt good.test.crt

echo "=== chain + correct hostname ==="
openssl verify -CAfile ca.crt -verify_hostname good.test good.test.crt

echo "=== chain + WRONG hostname ==="
openssl verify -CAfile ca.crt -verify_hostname wrong.name.test good.test.crt

echo "=== chain + purpose check, on a clientAuth-only certificate ==="
openssl verify -CAfile ca.crt clientonly.test.crt
openssl verify -CAfile ca.crt -purpose sslserver clientonly.test.crt
Expected result — click to reveal
plain text
=== chain only ===
good.test.crt: OK

=== chain + correct hostname ===
good.test.crt: OK

=== chain + WRONG hostname ===
CN = good.test
error 62 at 0 depth lookup: hostname mismatch
error good.test.crt: verification failed

=== chain + purpose check ===
clientonly.test.crt: OK
CN = clientonly.test
error 26 at 0 depth lookup: unsuitable certificate purpose
error clientonly.test.crt: verification failed

What to read out of this — look at the last two lines especially.

  • clientonly.test.crt: OK on the plain check. The chain is perfect. The certificate is genuine, in date, correctly signed by a CA we trust. By the chain-only measure it passes.
  • Add -purpose sslserver and it fails with error 26. The same certificate, unchanged. It carries extendedKeyUsage = clientAuth and cannot be used for a TLS server.
  • openssl verify runs neither the hostname check nor the purpose check by default. You must ask for both. That is why a bare openssl verify returning OK means far less than people assume — it has answered one of three questions.

🔑 The habit to build: when validating a server certificate, always run all three gates:

bash
openssl verify -CAfile ca.crt -untrusted chain.pem \
  -purpose sslserver -verify_hostname the.host.name leaf.crt

Anything less is a partial answer, and partial answers are how a certificate reaches production and fails only for real clients.

🎯 Interview questions — The checks

Q. What does a client check before accepting a server certificate?

Seven things, and it helps to group them:

Chain checks — build a path to a trusted anchor; verify every signature; check every certificate's validity dates at every depth; enforce CA constraints (CA:TRUE, keyCertSign, pathlen, name constraints).

Leaf checks — confirm Extended Key Usage permits serverAuth; match the requested hostname against the SAN.

Freshness — revocation, via CRL or OCSP, which in practice is frequently skipped or fails open.

The distinction worth making explicitly: hostname matching is not part of chain validation. It is a separate check on the leaf, which is why openssl verify reports OK for a certificate belonging to a different site unless you pass -verify_hostname. Same for purpose — you need -purpose sslserver.

And the one people forget: dates are checked at every depth. An expired intermediate breaks a leaf with a year left, and leaf-only monitoring never sees it.


Part B · The failures, one at a time

Each section here breaks one gate and shows what four different clients say about it. The point is not to memorise error strings — it is to build the reflex that turns an error message into a cause in about two seconds.

The test harness, used by every exercise in this part. Save it once:

bash
	cd ~/tls-lab/m07
	cat > pyc.py <<'EOF'
import ssl,socket,sys
host,port,ca=sys.argv[1],int(sys.argv[2]),sys.argv[3]
ctx=ssl.create_default_context(cafile=ca)
try:
    with socket.create_connection(("127.0.0.1",port),timeout=5) as s:
        with ctx.wrap_socket(s,server_hostname=host) as ss: print("PYTHON: OK")
except Exception as e:
    print("PYTHON:",type(e).__name__+":",str(e).replace("\n"," ")[:150])
EOF

	cat > try.sh <<'EOF'
#!/usr/bin/env bash
# try.sh <certfile> <hostname> [chainfile]
export no_proxy="*"
P=$((17000+RANDOM%1000))
openssl s_server -cert "$1" ${3:+-cert_chain "$3"} -key srv.key -accept $P -www >/dev/null 2>&1 &
PID=$!; sleep 1
curl -sS --cacert ca.crt --resolve "$2:$P:127.0.0.1" "https://$2:$P/" -o /dev/null 2>&1 | head -1 | sed 's/^/CURL:    /'
python3 pyc.py "$2" $P ca.crt 2>&1 | head -1
node -e "const tls=require('tls'),fs=require('fs');
const s=tls.connect({host:'127.0.0.1',port:$P,servername:'$2',ca:fs.readFileSync('ca.crt')},
  ()=>{console.log('NODE:    OK');s.end();});
s.on('error',e=>console.log('NODE:   ',e.code||e.message));" 2>&1 | tail -1
openssl s_client -connect 127.0.0.1:$P -servername "$2" -CAfile ca.crt -verify_hostname "$2" \
  </dev/null 2>&1 | grep -m1 'Verify return code' | sed 's/^/OPENSSL: /'
kill $PID 2>/dev/null; wait $PID 2>/dev/null
EOF
	chmod +x try.sh

A Go client is worth adding too if you have Go installed — it gives the most explicit messages of any client, and they appear in the matrix in Part C.

B1 · Expired, and not yet valid

The analogy — the visa that ran out, and the one that has not started.

The passport is genuine, the photo matches, the stamp is real. The visa inside it expired last month, so you are refused.

And the mirror image: a visa that becomes valid next January is refused today for the opposite reason. Both are date failures, and they look completely different in the error text.

The second one is worth taking seriously because it almost never means what it says. A "not yet valid" certificate nearly always means the client's clock is wrong, not that anyone issued a future-dated certificate.

🧪 Exercise B1.1 — Serve an expired certificate
bash
cd ~/tls-lab/m07
./try.sh expired.test.crt expired.test
Expected result — click to reveal
plain text
CURL:    curl: (60) SSL certificate problem: certificate has expired
PYTHON: SSLCertVerificationError: [SSL: CERTIFICATE_VERIFY_FAILED] certificate verify failed: certificate has expired (_ssl.c:1016)
NODE:    CERT_HAS_EXPIRED
OPENSSL: Verify return code: 10 (certificate has expired)

And with a Go client:

plain text
GO: tls: failed to verify certificate: x509: certificate has expired or is not yet valid:
    current time 2026-08-20T14:17:11Z is after 2024-04-01T00:00:00Z

What to read out of this.

  • All four agree, and all four are clear. Expiry is the one failure every client reports unambiguously. If you see any of these strings, you are done diagnosing — check the dates and renew.
  • Go's message is the most useful by a distance. It prints both timestamps: what it thinks the time is, and what the certificate says. That single line distinguishes "the certificate really is expired" from "this machine's clock is wrong" without any further investigation.
  • Verify return code: 10 — add it to your table alongside 18, 20, 21, 25, 47 and 62.

💡 The check that prevents this, from Module 03 (A3.2): openssl x509 -noout -checkend 2592000 — exit 0 means safe for 30 days, exit 1 means renew. Run it against the live endpoint and across the whole chain, per Module 05 (D3.1).

🧪 Exercise B1.2 — Now the mirror image
bash
cd ~/tls-lab/m07
./try.sh future.test.crt future.test
Expected result — click to reveal
plain text
CURL:    curl: (60) SSL certificate problem: certificate is not yet valid
PYTHON: SSLCertVerificationError: [SSL: CERTIFICATE_VERIFY_FAILED] certificate verify failed: certificate is not yet valid (_ssl.c:1016)
NODE:    CERT_NOT_YET_VALID
OPENSSL: Verify return code: 9 (certificate is not yet valid)

Go:

plain text
GO: tls: failed to verify certificate: x509: certificate has expired or is not yet valid:
    current time 2026-08-20T14:17:12Z is before 2030-01-01T00:00:00Z

What to read out of this.

  • Code 9 versus code 10. Adjacent numbers, opposite problems. Worth knowing both, because they lead to completely different investigations.
  • Go collapses them into one message — "has expired or is not yet valid" — and then distinguishes with is after versus is before. Read the preposition, not the headline.
  • In the real world this error is almost always a clock problem. As Module 03 (A3) noted, CAs deliberately backdate notBefore by an hour or so precisely because client clocks drift. A client reporting "not yet valid" is usually a device with a dead RTC battery, a VM restored from a snapshot, a container with no NTP, or an appliance that boots at the Unix epoch.

🔑 The diagnostic: compare date -u on the client with openssl x509 -noout -dates on the certificate. If notBefore is in the past by real UTC, the client is wrong, and no certificate change will fix it.

⚠️ This is why embedded and IoT fleets struggle with TLS: a device that boots with no valid clock cannot validate any certificate, so it needs a trusted time source before it can do anything — which is a chicken-and-egg problem if that time source is itself served over HTTPS.


B2 · Hostname mismatch

The analogy — a real passport, and the wrong face.

The document is completely genuine. Issued properly, in date, stamp verifies. It simply belongs to somebody else.

Nothing is wrong with the certificate. It is being presented for the wrong purpose, and refusing it is the officer doing their job correctly.

This is the failure that most often means "you are talking to the wrong server" rather than "someone made a mistake with a certificate" — which is exactly what the check exists to catch.

🧪 Exercise B2.1 — Ask for a name the certificate does not cover
bash
cd ~/tls-lab/m07
./try.sh good.test.crt wrong.name.test
Expected result — click to reveal
plain text
CURL:    curl: (60) SSL: no alternative certificate subject name matches target host name 'wrong.name.test'
PYTHON: SSLCertVerificationError: [SSL: CERTIFICATE_VERIFY_FAILED] certificate verify failed: Hostname mismatch, certificate is not valid for 'wrong.name.test'. (_ssl.c:1016)
NODE:    ERR_TLS_CERT_ALTNAME_INVALID
OPENSSL: Verify return code: 62 (hostname mismatch)

Go:

plain text
GO: tls: failed to verify certificate: x509: certificate is valid for good.test, not wrong.name.test

What to read out of this — every one of these messages is telling you something slightly different.

  • curl's phrasing carries a free diagnostic. The words "no alternative certificate subject name" mean curl looked at the SAN. Compare with the message from Module 03 for a SAN-less certificate — certificate subject name 'x' does not match — which has no "alternative" in it, because curl fell back to the Common Name. One word tells you whether the certificate even has a SAN.
  • Go is the most helpful again: it prints what the certificate is valid for and what you asked for, side by side. That immediately distinguishes a typo from a genuinely wrong server.
  • Node's ERR_TLS_CERT_ALTNAME_INVALID also names ALTNAME explicitly — same signal as curl's wording.
  • Code 62 is the one to recognise on sight. Chain fine, name wrong.

🔑 The four causes, in the order worth checking them:

  1. The name is genuinely not in the SAN. Reissue with it. openssl x509 -noout -ext subjectAltName shows what is actually there.
  2. Wildcard depth. *.example.com covers a.example.com but not a.b.example.com, and not bare example.com (Module 03, B3).
  3. The wrong certificate is being served — an SNI or virtual-host problem. Test with and without -servername (Module 06, D1.1). If they differ, the fix is in the web server config, not at the CA.
  4. You are genuinely talking to the wrong server — a stale DNS record, a wrong /etc/hosts entry, or a load balancer pointing somewhere unexpected. This is the case the check exists for, and it is worth ruling in before assuming a misconfiguration.

B3 · Untrusted root and incomplete chain — the identical twins

The analogy — two different problems, one shrug.

Problem A: the passport was issued by a country the officer has never heard of.

Problem B: the passport was issued by a perfectly recognised regional office, but you did not hand over the office's warrant, and the officer has no copy.

Completely different causes. Completely different fixes. And from the officer's side they produce exactly the same shrug: "I cannot get from this document to anything I trust."

That is why these two failures are so often misdiagnosed. The error cannot tell them apart, because at the moment of failure the client genuinely does not know which it is.

🧪 Exercise B3.1 — Run both, and compare the errors character by character
bash
cd ~/tls-lab/m07

echo "########## A: signed by a CA we do not trust ##########"
./try.sh untrusted.test.crt untrusted.test

echo
echo "########## B: our CA, but the intermediate is missing ##########"
./try.sh chain.test.crt chain.test

echo
echo "########## B-fixed: same certificate, chain supplied ##########"
./try.sh chain.test.crt chain.test int.crt
Expected result — click to reveal
plain text
########## A: signed by a CA we do not trust ##########
CURL:    curl: (60) SSL certificate problem: unable to get local issuer certificate
PYTHON: SSLCertVerificationError: [SSL: CERTIFICATE_VERIFY_FAILED] certificate verify failed: unable to get local issuer certificate (_ssl.c:1016)
NODE:    UNABLE_TO_VERIFY_LEAF_SIGNATURE
OPENSSL: Verify return code: 21 (unable to verify the first certificate)

########## B: our CA, but the intermediate is missing ##########
CURL:    curl: (60) SSL certificate problem: unable to get local issuer certificate
PYTHON: SSLCertVerificationError: [SSL: CERTIFICATE_VERIFY_FAILED] certificate verify failed: unable to get local issuer certificate (_ssl.c:1016)
NODE:    UNABLE_TO_VERIFY_LEAF_SIGNATURE
OPENSSL: Verify return code: 21 (unable to verify the first certificate)

########## B-fixed: same certificate, chain supplied ##########
PYTHON: OK
NODE:    OK
OPENSSL: Verify return code: 0 (ok)

Go, for both A and B:

plain text
GO: tls: failed to verify certificate: x509: certificate signed by unknown authority

What to read out of this — and this is the single most useful thing in the module.

A and B produce byte-for-byte identical errors in every client tested. Not similar. Identical. curl, Python, Node, Go and OpenSSL all report the same thing for two completely different problems with two completely different fixes.

  • Cause A — untrusted CA. Fix: install the CA's root in the client's trust store, or use a certificate from a CA the client already trusts.
  • Cause B — incomplete chain. Fix: serve fullchain.pem on the server. Nothing about the client is wrong.

Getting this backwards is expensive. The common wrong move is to treat B as A: add the intermediate to the client's trust store, watch it work, and declare victory — leaving every other client in the estate broken, forever, and creating a trust anchor that should not exist.

🔑 The one command that tells them apart, and you should reach for it immediately on seeing this error:

bash
openssl s_client -connect host:443 -servername host -showcerts </dev/null 2>/dev/null \
  | grep -c 'BEGIN CERTIFICATE'

Result 1 from a site whose certificate was issued by an intermediate → cause B, incomplete chain, fix the server.

Result 2 or more, still failingcause A, you do not trust the root.

💡 Node distinguishes them better than the others in one case. UNABLE_TO_VERIFY_LEAF_SIGNATURE here, versus DEPTH_ZERO_SELF_SIGNED_CERT for a self-signed leaf (section B4). OpenSSL likewise separates 21 from 18. Neither separates A from B — nothing does, because the information is not available at the point of failure.

⚠️ And remember why you may not see this at all in a browser. Chrome on Windows and macOS fetches the missing intermediate itself via AIA (Module 03, C5). So cause B commonly presents as "works in my browser, fails in the app" — which is a server fault wearing a client-fault costume.


B4 · Self-signed

The analogy — the passport you printed at home (Module 04, C1).

The officer is not accusing you of forgery. The document says exactly what it says. It is simply signed by you, and you are not on the list.

The distinguishing feature is that the chain has length one. There is no office, no warrant, nothing above it. That is why some clients report this differently from an untrusted CA: the shape of the failure is visibly different even though the outcome is the same.

🧪 Exercise B4.1 — Serve a self-signed certificate
bash
cd ~/tls-lab/m07
./try.sh selfsigned.test.crt selfsigned.test
Expected result — click to reveal
plain text
CURL:    curl: (60) SSL certificate problem: self-signed certificate
PYTHON: SSLCertVerificationError: [SSL: CERTIFICATE_VERIFY_FAILED] certificate verify failed: self-signed certificate (_ssl.c:1016)
NODE:    DEPTH_ZERO_SELF_SIGNED_CERT
OPENSSL: Verify return code: 18 (self-signed certificate)

Go:

plain text
GO: tls: failed to verify certificate: x509: certificate signed by unknown authority

What to read out of this.

  • curl, Python, Node and OpenSSL all name it specifically. self-signed certificate, DEPTH_ZERO_SELF_SIGNED_CERT, code 18. That precision is a gift — you know immediately there is no CA involved at all.
  • Go says the same thing it said for an untrusted CA and for a missing intermediate: certificate signed by unknown authority. Go collapses three distinct causes into one message. That is a deliberate design choice — from Go's point of view the outcome is identical, and it declines to speculate — but it does mean a Go error tells you less than a curl error here.
  • Code 18 versus code 19. 18 is a self-signed leaf — the whole chain is one certificate. 19 is self-signed certificate in chain: a real multi-level chain whose root you do not trust. Different shapes, adjacent numbers.

🔑 The two legitimate fixes, and the one illegitimate one (Module 04, C2.2):

  • --cacert ca.crt — extend trust to this specific certificate. Everything else is still checked.
  • ✅ Install it in the trust store — the same, but permanently and for all clients on the host.
  • -k / verify=False / rejectUnauthorized:false / InsecureSkipVerify:trueturns off verification entirely and accepts a certificate from anyone. This is not a smaller version of the first two; it is a different thing.

B5 · Wrong purpose — the failure nobody expects

The analogy — the bus licence at the airport.

Your licence is genuine, current, and definitely yours. You present it to fly a plane.

Refused — and not because anything is wrong with the licence. The categories on the back do not include aircraft.

This is the Module 03 driving-licence analogy arriving as a real error. And it is the failure people least expect, because every other gate passed: the chain is perfect, the dates are fine, the hostname matches.

🧪 Exercise B5.1 — Serve a certificate marked for client authentication only
bash
cd ~/tls-lab/m07

echo "=== what does the certificate say it is for? ==="
openssl x509 -in clientonly.test.crt -noout -ext extendedKeyUsage

echo "=== does it chain correctly? ==="
openssl verify -CAfile ca.crt clientonly.test.crt

echo "=== now serve it as a web server ==="
./try.sh clientonly.test.crt clientonly.test
Expected result — click to reveal
plain text
=== what does the certificate say it is for? ===
X509v3 Extended Key Usage:
    TLS Web Client Authentication

=== does it chain correctly? ===
clientonly.test.crt: OK

=== now serve it as a web server ===
CURL:    curl: (60) SSL certificate problem: unsuitable certificate purpose
PYTHON: SSLCertVerificationError: [SSL: CERTIFICATE_VERIFY_FAILED] certificate verify failed: unsuitable certificate purpose (_ssl.c:1016)
NODE:    INVALID_PURPOSE
OPENSSL: Verify return code: 26 (unsuitable certificate purpose)

Go:

plain text
GO: tls: failed to verify certificate: x509: certificate specifies an incompatible key usage

What to read out of this — the second block is the one that matters.

  • clientonly.test.crt: OK. The chain is flawless. Signed by a CA we trust, in date, correct signature, and openssl verify with no extra flags is perfectly happy.
  • And every real client refuses it. Because real clients check the purpose and a bare openssl verify does not. This is the concrete demonstration of the Part A2 lesson: a plain openssl verify answers one of three questions.
  • All four clients caught it, which is worth noting — EKU enforcement is not patchy the way revocation is. serverAuth is genuinely required.
  • Go's wording says "key usage" where everyone else says "purpose". It is talking about EKU, not the Key Usage extension. Slightly misleading, and worth knowing so you look in the right place.

🔑 Where this actually happens. Almost never with a public CA, which always issues serverAuth. It happens with internal CAs and hand-rolled certificates, where someone copied an extension block from a client-certificate recipe, or omitted EKU reasoning entirely.

It is also the standard symptom when a certificate intended for mutual TLS (Module 12) is missing clientAuth — the same failure in the other direction, and it usually surfaces as a generic handshake failure rather than a clear message.

💡 The check to add to any certificate validation script:

bash
openssl verify -CAfile ca.crt -purpose sslserver leaf.crt

One flag, and it catches a class of problem that a plain chain check reports as OK.

🎯 Interview questions — Reading validation failures

Q. unable to get local issuer certificate — what does it mean and how do you fix it?

It means the client could not build a path from the presented certificate to a trust anchor. Two completely different causes produce identical errors:

  1. The server is not sending its intermediates. Fix on the server: serve fullchain.pem — leaf first, then intermediates, never the root.
  2. The client does not trust the root. Fix on the client: install the root, or use a publicly trusted certificate.

They cannot be distinguished from the error message — curl, Python, Node, Go and OpenSSL all report the same thing. One command separates them:

bash
openssl s_client -connect host:443 -servername host -showcerts </dev/null 2>/dev/null | grep -c 'BEGIN CERTIFICATE'

One certificate from a site issued by an intermediate means cause 1.

The mistake to name: treating cause 1 as cause 2 — adding the intermediate to one client's trust store. It works, so it looks correct, and it leaves every other client broken while creating a trust anchor that should not exist.

And the reason it hides: Chrome on Windows and macOS fetches missing intermediates via AIA, so cause 1 usually presents as "works in my browser, fails in the app".

Q. A certificate passes openssl verify but real clients reject it. What could be wrong?

openssl verify with no flags checks the chain only. It does not check the hostname and does not check the purpose. So a certificate can pass while failing either.

The two common cases:

  • Hostname — the SAN does not cover the requested name. Add -verify_hostname host.
  • Purpose — the EKU does not include serverAuth, typically on an internally issued certificate. Add -purpose sslserver.

A third, from Module 04: a certificate with no SAN at all passes openssl verify -verify_hostname via the legacy Common Name fallback, and is rejected outright by browsers and Go.

The habit that follows: validate with all three gates, and check for SAN presence explicitly:

bash
openssl verify -CAfile ca.crt -untrusted chain.pem -purpose sslserver -verify_hostname host leaf.crt
openssl x509 -in leaf.crt -noout -ext subjectAltName || echo "NO SAN"

---"}

Part C · Reading errors across clients

C1 · The matrix

The analogy — the same refusal, five accents.

Five officers refuse you for the same reason and phrase it five different ways. One is precise, one is vague, one gives you both numbers so you can work it out yourself.

Learning the accents is the skill. Once you can map any of these phrasings onto a cause, TLS troubleshooting stops being guesswork.

Every row below was produced by running the failure against a live local server, not copied from documentation.

CausecurlPython sslNode · OpenSSL code
Expiredcertificate has expiredcertificate has expiredCERT_HAS_EXPIRED · 10
Not yet validcertificate is not yet validcertificate is not yet validCERT_NOT_YET_VALID · 9
Hostname mismatchno alternative certificate subject name matches target host nameHostname mismatch, certificate is not valid for 'x'ERR_TLS_CERT_ALTNAME_INVALID · 62
Untrusted CAunable to get local issuer certificateunable to get local issuer certificateUNABLE_TO_VERIFY_LEAF_SIGNATURE · 21
Incomplete chainunable to get local issuer certificate ⚠️ identicalunable to get local issuer certificate ⚠️ identicalUNABLE_TO_VERIFY_LEAF_SIGNATURE · 21 ⚠️
Self-signedself-signed certificateself-signed certificateDEPTH_ZERO_SELF_SIGNED_CERT · 18
Untrusted root in a real chainself-signed certificate in certificate chainself-signed certificate in certificate chainSELF_SIGNED_CERT_IN_CHAIN · 19
Wrong EKUunsuitable certificate purposeunsuitable certificate purposeINVALID_PURPOSE · 26
No SAN (Module 04)accepted — CN fallbackaccepted — CN fallbackaccepted · 0

Go, separately — because it behaves differently

CauseGo error
Expired / not yet validx509: certificate has expired or is not yet valid: current time … is after/before …
Hostname mismatchx509: certificate is valid for good.test, not wrong.name.test
Untrusted CA · incomplete chain · self-signedx509: certificate signed by unknown authorityall three, one message
Wrong EKUx509: certificate specifies an incompatible key usage
No SANx509: certificate relies on legacy Common Name field, use SANs insteadrejected
Four things this matrix tells you that no single error message can.

1. Untrusted CA and incomplete chain are indistinguishable in every client. Count the certificates the server sends; that is the only way to separate them.

2. Go is the most informative on dates and hostnames — it prints both values — and the least informative on chain problems, collapsing three causes into one message. If you have a choice of client for debugging, use Go for name and date problems and curl or Node for chain problems.

3. Go is the strictest client here. It is the only one that rejects a SAN-less certificate, and it says so explicitly. If it works in Go, it works everywhere.

4. curl, Python and Node all wrap OpenSSL, which is why their wording is nearly identical and maps directly onto OpenSSL's numeric codes. Learn the numbers and you have learned three clients at once.


C2 · When more than one thing is wrong

The analogy — the officer stops at the first problem they happen to notice.

Your visa expired and the photo is not you. One officer glances at the date first and says "expired". Another looks at the face first and says "wrong person". Both are right, and neither has told you the whole story.

If you renew the visa and come back, the second officer's objection is still waiting for you.

🧪 Exercise C2.1 — Break two gates at once and see the clients disagree
bash
cd ~/tls-lab/m07

echo "########## expired AND wrong hostname ##########"
./try.sh expired.test.crt totally.wrong

echo
echo "########## untrusted CA AND wrong hostname ##########"
./try.sh untrusted.test.crt nope.test
Expected result — click to reveal
plain text
########## expired AND wrong hostname ##########
CURL:    curl: (60) SSL certificate problem: certificate has expired
PYTHON: SSLCertVerificationError: ... Hostname mismatch, certificate is not valid for 'totally.wrong'.
GO:      x509: certificate has expired or is not yet valid: current time … is after 2024-04-01T00:00:00Z

########## untrusted CA AND wrong hostname ##########
CURL:    curl: (60) SSL certificate problem: unable to get local issuer certificate
GO:      x509: certificate is valid for untrusted.test, not nope.test

What to read out of this — this is the module's most practically useful finding.

On the same connection, curl said "expired" and Python said "hostname mismatch". Both certificates had both faults. The clients simply check in different orders and report whichever they hit first.

And in the second case the disagreement flips: curl reported the chain problem, Go reported the hostname problem. So it is not even a consistent ordering between clients — it depends on the failure.

🔑 The consequence, and it is the thing to actually take away: "fix the error the client reported" is a trap. You fix it, redeploy, and hit the next fault — sometimes several times, with a deploy cycle between each.

So do not debug from the error. Audit the certificate directly, and check every gate in one pass before changing anything:

bash
openssl s_client -connect host:443 -servername host -showcerts </dev/null 2>/dev/null > c.pem
openssl x509 -in c.pem -noout -subject -issuer -dates -ext subjectAltName,extendedKeyUsage
grep -c 'BEGIN CERTIFICATE' c.pem
openssl verify -CAfile ca.crt -untrusted c.pem -purpose sslserver -verify_hostname host c.pem

Four commands, every gate covered, no guessing about which fault the client happened to mention.

💡 This is also why the certinfo script from Module 03 and tlsinfo from Module 06 are built to report everything rather than stop at the first problem. A tool that lists all faults at once turns three deploy cycles into one.

🎯 Interview questions — Diagnosing from errors

Q. Two engineers report different TLS errors for the same endpoint. How is that possible?

Because validation has several independent gates, and clients check them in different orders and stop at the first failure. If a certificate is both expired and wrong-hostname, curl may report the expiry while Python reports the hostname — I have seen exactly that on the same connection.

It can also be genuinely different situations that look the same: one engineer behind a TLS-inspecting proxy sees a re-issued certificate; one on a machine with the internal root installed passes where another fails; one uses a browser that repairs the chain via AIA while the other uses curl, which does not.

The right response is not to reconcile the error messages — it is to audit the certificate directly and check every gate in one pass: chain length, dates at every depth, SAN, EKU, and issuer. Then you know the full set of faults instead of whichever one a particular client mentioned.

What this signals: that you treat an error message as one observation rather than as the diagnosis.


Part D · Verification in your own code

D1 · The footguns, and what to do instead

The analogy — dismissing the officer entirely.

There are two ways to get past a border post with an unusual document.

Add your government to their list. They still check the stamp, the date, the photo and the purpose. You have extended trust deliberately, and everything else still works.

Send the officer home. Now anyone walks through with anything.

These are not degrees of the same thing. They are opposites — and in code they are one keyword apart.

Language❌ Disables verification entirely✅ Adds trust properly
curl-k / --insecure--cacert ca.crt
wget--no-check-certificate--ca-certificate=ca.crt
Pythonverify=False · _create_unverified_context()verify='/path/ca.crt' · cafile= · REQUESTS_CA_BUNDLE
GoInsecureSkipVerify: trueRootCAs: pool from x509.NewCertPool()
Node.jsrejectUnauthorized: false · NODE_TLS_REJECT_UNAUTHORIZED=0ca: fs.readFileSync(...) · NODE_EXTRA_CA_CERTS
JavaA custom all-trusting TrustManagerkeytool -importcert -cacerts · -Djavax.net.ssl.trustStore
OpenSSL CLI(omitting -verify_return_error)-CAfile ca.crt
Every item in the left column reduces TLS to encryption with an unknown party.

The connection is still encrypted. It is encrypted to whoever answered, which may be an attacker on the path. As Module 01 (Part C1) established, encryption without authentication defends against passive eavesdropping only, and the attack that certificates exist to prevent is the active one.

These flags almost always arrive during an incident — something failed, someone needed it working, and this made it work. They are then never removed, because nothing fails afterwards.

Grep for them. They are the highest-value five-minute audit you can run on a codebase.

🧪 Exercise D1.1 — Prove the difference between adding trust and removing checking
bash
cd ~/tls-lab/m07
export no_proxy="*"
P=17999
openssl s_server -cert selfsigned.test.crt -key srv.key -accept $P -www >/dev/null 2>&1 &
PID=$!; sleep 1

echo "=== 1. no trust configured ==="
curl -sS --resolve selfsigned.test:$P:127.0.0.1 https://selfsigned.test:$P/ -o /dev/null 2>&1 | head -1

echo "=== 2. trust THIS certificate, correct hostname ==="
curl -sS --cacert selfsigned.test.crt --resolve selfsigned.test:$P:127.0.0.1 \
  https://selfsigned.test:$P/ -o /dev/null -w 'HTTP %{http_code}\n' 2>&1 | head -1

echo "=== 3. trust THIS certificate, WRONG hostname ==="
curl -sS --cacert selfsigned.test.crt --resolve other.name:$P:127.0.0.1 \
  https://other.name:$P/ -o /dev/null 2>&1 | head -1

echo "=== 4. -k ==="
curl -sSk --resolve other.name:$P:127.0.0.1 https://other.name:$P/ \
  -o /dev/null -w 'HTTP %{http_code}\n' 2>&1 | head -1

kill $PID 2>/dev/null
Expected result — click to reveal
plain text
=== 1. no trust configured ===
curl: (60) SSL certificate problem: self-signed certificate

=== 2. trust THIS certificate, correct hostname ===
HTTP 200

=== 3. trust THIS certificate, WRONG hostname ===
curl: (60) SSL: no alternative certificate subject name matches target host name 'other.name'

=== 4. -k ===
HTTP 200

What to read out of this — compare cases 3 and 4 carefully.

  • Case 3 still failed, even though we explicitly trusted the certificate. --cacert extended trust for one specific CA and left every other gate running — so the hostname check still fired. That is what a safe workaround looks like: it fixes exactly one thing.
  • Case 4 succeeded on the same wrong hostname. -k did not extend trust; it switched off validation. No chain check, no hostname check, no expiry check, no purpose check. An attacker presenting a certificate generated ten seconds ago would also have got HTTP 200.
  • Cases 2 and 4 both print HTTP 200. From the outside they look like the same success. That is precisely why -k survives in codebases: it works, and nothing visibly indicates that the security property is gone.

🔑 The rule, stated the way it is worth saying in an interview: "--cacert is a trust decision; -k is the absence of one. They both make the error go away, and only one of them is still doing TLS properly."

Now imagine this at 500 hosts. -k in a deployment script means every host in the fleet will accept any certificate from anything that answers on that address — a compromised DNS entry, a misrouted load balancer, an attacker on the network path. And because it never fails, no monitoring will ever tell you. The fix is to distribute the internal root properly (Module 05, A3) and treat these flags as build-breaking findings.

🎯 Interview questions — Verification in code

Q. What is wrong with InsecureSkipVerify: true / verify=False / -k?

They disable certificate validation entirely — chain, dates, hostname and purpose. The connection is still encrypted, but to whoever answered, which may be an attacker on the path. It removes the half of TLS that defends against an active attacker and keeps only the half that defends against a passive one.

The correct fix is nearly always to supply the CA--cacert, RootCAs, ca:, NODE_EXTRA_CA_CERTS, REQUESTS_CA_BUNDLE — which extends trust deliberately while leaving every other check running. I can demonstrate the difference: with --cacert a wrong hostname still fails; with -k it succeeds.

The organisational point: these flags almost always arrive during an incident and are never removed, because nothing fails afterwards. Grepping a codebase for all of them across languages is one of the highest-value quick audits there is, and it belongs in CI rather than in a periodic review.

The honest exception: a genuinely throwaway local test against a certificate you just generated. Even then --cacert is one flag longer and keeps the habit intact.

---"}

Part E · Putting it together

E1 · How this all fits — the complete picture

Diagram source
flowchart TD
    S["📜 Certificate + chain<br>arrives in the handshake"] --> G1{"1. Can I build a path<br>to a trusted anchor?"}
    G1 -->|"no"| E1["❌ 20 / 21<br>unable to get local issuer<br>⚠️ TWO causes, one error"]
    G1 -->|"self-signed leaf"| E2["❌ 18<br>self-signed certificate"]
    G1 -->|"yes"| G2{"2. Every signature valid?<br>3. Every cert in date?"}
    G2 -->|"no"| E3["❌ 9 / 10<br>not yet valid / expired<br>at ANY depth"]
    G2 -->|"yes"| G3{"4. CA constraints OK?<br>pathlen · name constraints"}
    G3 -->|"no"| E4["❌ 25 / 47"]
    G3 -->|"yes"| G4{"5. EKU allows serverAuth?"}
    G4 -->|"no"| E5["❌ 26<br>unsuitable purpose"]
    G4 -->|"yes"| G5{"6. Hostname in the SAN?"}
    G5 -->|"no"| E6["❌ 62<br>hostname mismatch"]
    G5 -->|"yes"| G6{"7. Revoked?<br>often skipped"}
    G6 -->|"no"| OK["✅ ACCEPTED"]
    style OK fill:#d5e8d4,stroke:#82b366,stroke-width:2px
    style E1 fill:#ffcccc,stroke:#cc0000,stroke-width:2px
    style E6 fill:#ffe6cc,stroke:#d79b00,stroke-width:2px
    style G6 fill:#f5f5f5,stroke:#999,stroke-dasharray: 4 4

Seven gates, and the certificate must pass all of them. Two things to carry away from the shape of this diagram:

The red box at the top has two arrows into it from different causes. That is the module's central practical lesson — unable to get local issuer means either "I don't trust your CA" or "you didn't send your intermediate", and no client can tell you which.

The dashed box at the bottom is dashed on purpose. Revocation is the gate most clients skip or fail open on, which is why it gets an entire module of its own next.


E2 · Production practice

HabitWhy
Validate with all three gates: chain, -purpose sslserver, -verify_hostnameA bare openssl verify answers one of three questions and returns OK for certificates real clients reject
On unable to get local issuer, count the certificates the server sends firstIt is the only way to separate "untrusted CA" from "incomplete chain" — the errors are identical
Never fix an incomplete chain by editing a client's trust storeIt fixes one client, hides a server fault, and creates a trust anchor that should not exist
Audit the certificate directly rather than chasing the reported errorClients check gates in different orders, so the reported error is one fault, not all of them
Test with a Go client, or a browser, as well as curlGo is the strictest — it alone rejects SAN-less certificates. If it passes in Go, it passes everywhere
Treat "not yet valid" as a clock problem until proven otherwiseCAs backdate notBefore deliberately. It is almost always a dead RTC, a restored snapshot, or missing NTP
Generate a deliberately expired certificate with openssl ca -enddate to test alertingThe only honest way to confirm your monitoring and error handling actually fire
Grep the codebase for -k, verify=False, InsecureSkipVerify, rejectUnauthorized:falseThey arrive during incidents and are never removed, because nothing fails afterwards
Supply CAs by bundle — --cacert, RootCAs, NODE_EXTRA_CA_CERTS, SSL_CERT_FILEExplicit, visible in config, survives OS upgrades, and does not grant that CA authority over the whole host
Check -ext subjectAltName explicitly as a hard gate before deployingOpenSSL and curl accept SAN-less certificates via the CN fallback; browsers and Go do not

E3 · Capstone exercise

Write a diagnostic that identifies which gate failed, not just that something did — including separating the two causes that share an error message. This exercises every section of the module.

Brief. Write a script tlsdiag that takes a hostname and port and reports:

  1. Whether the connection succeeds at all, and if not which gate failed, by name
  2. For unable to get local issuer, which of the two causes it is — by counting the certificates sent
  3. Every fault it can find, not just the first — dates at every depth, SAN presence and match, EKU, chain length
  4. A one-line verdict with the specific fix, not just the error text
Model answer — attempt it first, then click
bash
#!/usr/bin/env bash
# tlsdiag - identify WHICH validation gate failed, and all faults at once
set -uo pipefail
host="${1:?usage: tlsdiag <host> [port] [cafile]}"; port="${2:-443}"; ca="${3:-}"
CAOPT=""; [ -n "$ca" ] && CAOPT="-CAfile $ca"
tmp=$(mktemp -d); trap 'rm -rf "$tmp"' EXIT

printf '\n  %s:%s\n  %s\n\n' "$host" "$port" "$(printf '=%.0s' {1..58})"

# --- fetch everything the server sends, once ---
# shellcheck disable=SC2086
openssl s_client -connect "$host:$port" -servername "$host" -showcerts $CAOPT \
  </dev/null 2>"$tmp/err" | sed -n '/BEGIN CERT/,/END CERT/p' > "$tmp/chain.pem"

if [ ! -s "$tmp/chain.pem" ]; then
  echo "  ❌ No certificate received. Not a TLS port, or the connection failed."
  sed -n '1,3p' "$tmp/err" | sed 's/^/     /'; exit 1
fi

nsent=$(grep -c 'BEGIN CERTIFICATE' "$tmp/chain.pem")
( cd "$tmp" && csplit -sz -f c- -b '%02d.pem' chain.pem '/BEGIN CERTIFICATE/' '{*}' )
leaf="$tmp/c-00.pem"

# --- the authoritative verdict from the live connection ---
# shellcheck disable=SC2086
vcode=$(openssl s_client -connect "$host:$port" -servername "$host" $CAOPT \
        -verify_hostname "$host" </dev/null 2>&1 \
        | grep -m1 'Verify return code' | sed 's/.*code: //')

faults=0
fault() { printf '  ❌ %-22s %s\n' "$1" "$2"; faults=$((faults+1)); }
ok()    { printf '  ✅ %-22s %s\n' "$1" "$2"; }

# --- gate 1: chain completeness ---
leafiss=$(openssl x509 -in "$leaf" -noout -issuer | sed 's/^issuer=//')
leafsub=$(openssl x509 -in "$leaf" -noout -subject | sed 's/^subject=//')
if [ "$leafsub" = "$leafiss" ]; then
  fault "chain" "SELF-SIGNED - no CA involved (verify code 18)"
elif [ "$nsent" -eq 1 ]; then
  fault "chain" "server sent ONLY 1 certificate - intermediates MISSING"
  printf '     └─ fix on the SERVER: serve fullchain.pem, not just the leaf\n'
else
  ok "chain" "$nsent certificates sent"
fi

# --- gate 3: dates, at EVERY depth ---
d=0
for c in "$tmp"/c-*.pem; do
  sub=$(openssl x509 -in "$c" -noout -subject | sed 's/^subject=//' | cut -c1-40)
  end=$(openssl x509 -in "$c" -noout -enddate | cut -d= -f2)
  if ! openssl x509 -in "$c" -noout -checkend 0 >/dev/null 2>&1; then
    fault "dates depth=$d" "EXPIRED $end  ($sub)"
  elif ! openssl x509 -in "$c" -noout -checkend 2592000 >/dev/null 2>&1; then
    fault "dates depth=$d" "expires within 30 days: $end  ($sub)"
  else
    ok "dates depth=$d" "$end"
  fi
  d=$((d+1))
done

# --- gate 5: purpose ---
eku=$(openssl x509 -in "$leaf" -noout -ext extendedKeyUsage 2>/dev/null | tail -1 | sed 's/^ *//')
case "$eku" in
  *"Web Server Authentication"*) ok "purpose" "$eku" ;;
  "")                            fault "purpose" "no EKU present" ;;
  *)                             fault "purpose" "NOT valid for a server: $eku" ;;
esac

# --- gate 6: SAN and hostname ---
sans=$(openssl x509 -in "$leaf" -noout -ext subjectAltName 2>/dev/null | tail -n +2 | tr -d ' \n')
if [ -z "$sans" ]; then
  fault "SAN" "NO SAN - browsers and Go reject this (curl/openssl will not warn you)"
else
  # shellcheck disable=SC2086
  if openssl verify $CAOPT -untrusted "$tmp/chain.pem" -verify_hostname "$host" "$leaf" >/dev/null 2>&1; then
    ok "hostname" "$host is covered"
  else
    fault "hostname" "$host NOT covered by: $sans"
  fi
fi

# --- the disambiguation that matters ---
printf '\n  %-24s %s\n' "Live verify code:" "$vcode"
case "$vcode" in
  0*)  printf '\n  ✅ VERDICT: this endpoint validates cleanly.\n' ;;
  20*|21*)
    if [ "$nsent" -eq 1 ]; then
      printf '\n  🔧 VERDICT: INCOMPLETE CHAIN. The server sent 1 certificate.\n'
      printf '     Fix on the SERVER: cat leaf.crt intermediate.crt > fullchain.pem\n'
      printf '     NOT by adding anything to a client trust store.\n'
    else
      printf '\n  🔧 VERDICT: UNTRUSTED CA. The chain is complete but its root is not trusted here.\n'
      printf '     Fix on the CLIENT: supply the root with --cacert / RootCAs / NODE_EXTRA_CA_CERTS.\n'
    fi ;;
  18*) printf '\n  🔧 VERDICT: SELF-SIGNED. Use --cacert to trust it, or issue from a real CA. Never -k.\n' ;;
  62*) printf '\n  🔧 VERDICT: HOSTNAME MISMATCH. Compare with and without -servername:\n'
       printf '     a difference means a virtual-host problem, not a certificate problem.\n' ;;
  26*) printf '\n  🔧 VERDICT: WRONG PURPOSE. The certificate lacks serverAuth in its EKU. Reissue.\n' ;;
  9*|10*) printf '\n  🔧 VERDICT: DATE FAILURE. Check the depth above, and check the client clock with date -u.\n' ;;
esac
[ "$faults" -gt 1 ] && printf '\n  ⚠️  %d faults found. Fix ALL of them - clients report only the first.\n' "$faults"
printf '\n'

Try it against your lab and against the internet:

bash
chmod +x tlsdiag
cd ~/tls-lab/m07
# start any failure server from Part B, then:
./tlsdiag good.test 17001 ca.crt
./tlsdiag www.google.com 443

The four design decisions this capstone is really testing.

1. It fetches the chain once and analyses it offline. Every gate is then checked against the same snapshot. Opening a new connection per check would be slower and could give inconsistent answers if the server is behind a load balancer serving different certificates.

2. It reports every fault, then the verdict. Requirement 3 exists because of Exercise C2.1: clients stop at the first failure and they disagree about which that is. A tool that lists all faults turns three deploy cycles into one.

3. The verdict for code 20/21 branches on the certificate count. This is the heart of it. The same error code produces two completely different instructionsfix the server or fix the client — and the count is the only evidence that separates them. Getting this branch right is what makes the tool worth having.

4. Every verdict names where the fix goes. "Fix on the SERVER" versus "fix on the CLIENT" is the sentence that prevents the most common wrong move in TLS operations.

Where this goes next: add revocation status (Module 09), OCSP stapling (Module 08), and cipher and protocol auditing (Module 13). Together with certinfo from Module 03 and tlsinfo from Module 06, this becomes the single tool you reach for.


E4 · Official documentation — what to bookmark and how to read it

The single most useful reference for this module: openssl verify — the VERIFY OPERATION and DIAGNOSTICS sections. The DIAGNOSTICS section is the complete numbered list of every error code OpenSSL can return, with the exact text for each.

Make it a reflex: when you see a verify code you do not recognise, look it up there before searching the web. The list is authoritative, it is short, and because curl, Python and Node all wrap OpenSSL, it explains their errors too.

Core reference pages

LinkWhat it is for
openssl verify manualEvery error code and its exact text, plus -purpose, -verify_hostname, -show_chain
RFC 5280 §6 — Certification Path ValidationThe algorithm every client implements. §6.1.3 is the per-certificate checks
RFC 9525 — Service Identity in TLSHostname matching, definitively. Obsoletes RFC 6125 — cite this one
RFC 5280 §4.2.1.12 — Extended Key UsageWhat serverAuth means and why a clientAuth-only certificate is refused
curl — SSL certificate verificationThe page curl's error 60 points at. Explains --cacert versus -k properly
Node.js TLS documentation · Go crypto/x509The error constants each uses — DEPTH_ZERO_SELF_SIGNED_CERT, CertificateInvalidError and friends
Python ssl — security considerationsWhy create_default_context() is the right entry point, and what verify=False really turns off
openssl ca-startdate / -enddateHow to generate deliberately expired or future certificates for testing
badssl.comHosted versions of every failure in this module, if you would rather not build the lab

The verify codes worth knowing by heart

CodeTextWhat to do
0ok
9certificate is not yet validCheck the client's clock first
10certificate has expiredRenew — and check every depth, not just the leaf
18self-signed certificate--cacert, or issue from a real CA
19self-signed certificate in chainA real chain whose root you do not trust
20unable to get local issuer certificateCount the certificates sent — server or client fix
21unable to verify the first certificateSame as 20, seen from s_client
24invalid CA certificateSomething in the chain lacks CA:TRUE or keyCertSign
25path length constraint exceededA pathlen violation (Module 05)
26unsuitable certificate purposeEKU lacks serverAuth. Reissue
47permitted subtree violationA name constraint violation (Module 05)
62hostname mismatchCompare with and without -servername

The offline alternative

bash
openssl verify -help                    # every flag
man openssl-verify                      # includes the full DIAGNOSTICS code list
openssl errstr 0x1416F086               # decode a raw OpenSSL error number
curl --help all | grep -i cert          # curl's certificate options
🧪 Exercise E4.1 — Read the purpose list from the CLI
bash
openssl verify -help 2>&1 | grep -A2 purpose
openssl x509 -purpose -in ~/tls-lab/m07/good.test.crt -noout | head -14
Expected result — click to reveal
plain text
 -purpose val                certificate chain purpose

Certificate purposes:
SSL client : No
SSL client CA : No
SSL server : Yes
SSL server CA : No
Netscape SSL server : Yes
Netscape SSL server CA : No
S/MIME signing : No
S/MIME signing CA : No
S/MIME encryption : No
S/MIME encryption CA : No
CRL signing : No
CRL signing CA : No
Any Purpose : Yes

What to read out of this.

  • openssl x509 -purpose answers "what is this certificate actually allowed to do?" in one command, as a checklist. It is much easier to read than decoding EKU and Key Usage yourself, and it is the fastest way to confirm a purpose failure.
  • SSL server : Yes and SSL client : No on this certificate. Run the same command against clientonly.test.crt and the two swap over — which is exactly why it fails as a web server.
  • Any Purpose : Yes is not a contradiction. It means the certificate does not forbid other uses, only that its EKU does not list them. -purpose any would therefore pass, which is why the flag matters: you must ask about the purpose you actually need.
  • The ... CA : No rows confirm basicConstraints: CA:FALSE from another angle. A CA certificate would show Yes on the CA rows.

💡 openssl x509 -purpose -noout is worth adding to your certificate review habit. It turns two extensions and a set of rules into a yes/no table, and it catches the purpose mistakes that a chain check reports as OK.


E5 · Self-assessment

Answer each out loud before opening it. If your answer is materially thinner than the one behind the toggle, that topic is worth a second pass.

1. What does a client check before accepting a server certificate?

Build a path to a trusted anchor; verify every signature; check validity dates at every depth; enforce CA constraints; check EKU permits serverAuth; match the hostname against the SAN; check revocation.

Hostname and purpose are not part of chain validation — they are separate gates, which is why a bare openssl verify can say OK for a certificate real clients reject.

2. unable to get local issuer certificate — which two causes, and how do you tell them apart?

Either the server is not sending its intermediates, or the client does not trust the root. Every client reports both identically.

Separate them by counting: openssl s_client ... -showcerts | grep -c 'BEGIN CERTIFICATE'. One certificate from a site issued by an intermediate means incomplete chain — fix the server. A complete chain still failing means untrusted root — fix the client.

3. Why is fixing an incomplete chain on the client the wrong move?

It works for that one client, hides a server misconfiguration that will break every other client, must be repeated forever on every new host, and installs a trust anchor that should not exist. The fault is on the server; the fix belongs there.

4. A certificate passes openssl verify but browsers reject it. What are the possibilities?

The hostname is not in the SAN; the EKU lacks serverAuth; or there is no SAN at all — which OpenSSL and curl accept via the legacy CN fallback while browsers and Go reject it.

Validate with all three gates: -purpose sslserver -verify_hostname host, plus an explicit -ext subjectAltName check.

5. Two engineers get different errors for the same endpoint. How?

Clients check the gates in different orders and stop at the first failure, so a certificate with two faults produces different errors in different clients — I have seen curl report "expired" while Python reported "hostname mismatch" on the same connection.

The response is not to reconcile the messages but to audit the certificate directly and find every fault in one pass.

6. Client reports "certificate is not yet valid". First hypothesis?

The client's clock. CAs backdate notBefore deliberately because clocks drift, so a genuinely future-dated certificate is rare. Usually a dead RTC battery, a restored snapshot, a container without NTP, or a device booting at the epoch.

Compare date -u on the client with openssl x509 -noout -dates.

7. What does verify code 26 mean and where does it come from?

unsuitable certificate purpose — the certificate's Extended Key Usage does not include serverAuth. The chain can be perfect and a plain openssl verify will say OK; only -purpose sslserver catches it.

It happens almost exclusively with internal CAs and hand-written extension blocks. The mirror case is a missing clientAuth breaking mutual TLS.

8. Which client is strictest, and why does that matter?

Go. It is the only common client that rejects a SAN-less certificate, and it says so explicitly: certificate relies on legacy Common Name field, use SANs instead. If a certificate passes in Go it will pass anywhere.

The trade-off: Go collapses untrusted CA, incomplete chain and self-signed into one message, so it is the least useful client for diagnosing chain problems.

9. --cacert versus -k — what is the actual difference?

--cacert adds trust for one specific CA and leaves every other gate running — a wrong hostname still fails. -k removes validation entirely, so any certificate from anyone is accepted.

Both make the error disappear and both print HTTP 200, which is exactly why -k survives in codebases. Only one is still doing TLS properly.

10. How would you test that your certificate-expiry alerting actually works?

Issue a deliberately expired certificate with openssl ca -startdate -enddate, serve it on a test endpoint, and confirm the alert fires. openssl x509 -req cannot set explicit dates on OpenSSL 3.0, which is why openssl ca is the tool.

Changing the system clock is the alternative and it breaks other things. One flag is cheaper.

11. Which gate do clients most commonly skip?

Revocation. Most clients either do not check it or fail open when the responder is unreachable, so a revoked certificate frequently keeps working. That is Module 09's entire subject, and it is the reason certificate lifetimes are being cut so aggressively.


E6 · Command reference — everything from this module

The commands introduced in Module 07, grouped by what you are trying to achieve. The ones marked ⭐ are genuinely daily-use.

Validate properly — all three gates

bash
openssl verify -CAfile ca.crt leaf.crt                              # chain only - INCOMPLETE answer
openssl verify -CAfile ca.crt -untrusted chain.pem \
  -purpose sslserver -verify_hostname host leaf.crt                 # ⭐ all three gates
openssl verify -CAfile ca.crt -untrusted chain.pem -show_chain leaf.crt   # ⭐ where did it break?
openssl x509 -purpose -in leaf.crt -noout                           # ⭐ what is it allowed to do?
openssl x509 -in leaf.crt -noout -ext subjectAltName || echo "NO SAN"     # ⭐ hard gate

Diagnose a live endpoint

bash
openssl s_client -connect h:443 -servername h -CAfile ca.crt -verify_hostname h </dev/null 2>&1 \
  | grep 'Verify return code'                                       # ⭐ the verdict
openssl s_client -connect h:443 -servername h -showcerts </dev/null 2>/dev/null \
  | grep -c 'BEGIN CERTIFICATE'                                     # ⭐ SEPARATES code 20 from 21
openssl s_client -connect h:443 -servername h -showcerts </dev/null 2>/dev/null \
  | openssl crl2pkcs7 -nocrl -certfile /dev/stdin | openssl pkcs7 -print_certs -noout   # ⭐ every cert
openssl s_client -connect h:443 -servername h </dev/null 2>/dev/null \
  | openssl x509 -noout -dates -ext subjectAltName,extendedKeyUsage # ⭐ the leaf's facts

Build test certificates that fail on purpose

bash
openssl ca -config ca.cnf -extensions srv -batch -notext -in t.csr -out expired.crt \
  -startdate 20240101000000Z -enddate 20240401000000Z               # ⭐ deliberately expired
openssl ca -config ca.cnf -extensions srv -batch -notext -in t.csr -out future.crt \
  -startdate 20300101000000Z -enddate 20310101000000Z               # not yet valid
# wrong EKU: set extendedKeyUsage=clientAuth in the extfile
# wrong host: set subjectAltName to a name you will not request
# untrusted:  sign with a second CA the client does not have

Test with each client

bash
curl -sS --cacert ca.crt --resolve h:P:127.0.0.1 https://h:P/ -o /dev/null    # ⭐ no /etc/hosts needed
curl -sS -v https://h/ 2>&1 | grep -iE 'SSL certificate|subject|issuer'      # ⭐ curl's view
python3 -c "import ssl,socket;ctx=ssl.create_default_context(cafile='ca.crt');ctx.wrap_socket(socket.create_connection(('h',443)),server_hostname='h')"
node -e "require('tls').connect({host:'h',port:443,servername:'h'},()=>console.log('OK')).on('error',e=>console.log(e.code))"

Supply trust properly — never disable it

bash
curl --cacert ca.crt https://host/                     # ⭐ curl
export SSL_CERT_FILE=/path/ca.crt                      # ⭐ OpenSSL-based tools
export REQUESTS_CA_BUNDLE=/path/ca.crt                 # ⭐ Python requests
export NODE_EXTRA_CA_CERTS=/path/ca.crt                # ⭐ Node.js
keytool -importcert -cacerts -alias internal -file ca.crt   # Java
# ❌ NEVER: -k · --no-check-certificate · verify=False · InsecureSkipVerify · rejectUnauthorized:false
The four-command validation triage. Nothing changes anything, and together they identify which gate failed and where the fix belongs:
bash
openssl s_client -connect host:443 -servername host -verify_hostname host </dev/null 2>&1 | grep 'Verify return code'
openssl s_client -connect host:443 -servername host -showcerts </dev/null 2>/dev/null | grep -c 'BEGIN CERT'
openssl s_client -connect host:443 -servername host </dev/null 2>/dev/null \
  | openssl x509 -noout -dates -ext subjectAltName,extendedKeyUsage
openssl s_client -connect host:443 </dev/null 2>/dev/null | openssl x509 -noout -subject   # without SNI

Which gate failed · server-fix or client-fix · what the leaf actually says · is it an SNI problem.


Next — Module 08 · Deploying TLS: NGINX, Apache & the Chain-Order Trap.

You can now issue a certificate, build a chain, and tell exactly why a client rejected one. Module 08 puts it on a real server: nginx and Apache configuration line by line, why ssl_certificate needs fullchain.pem and what breaks when it does not, private key permissions and ownership, HTTP-to-HTTPS redirects done properly, HSTS and the preload trap you cannot undo, OCSP stapling configuration, and the reload-versus-restart distinction that causes more "but we renewed it" incidents than anything else.

Official reading ahead of it: nginx ngx_http_ssl_module and Apache mod_ssl.

📚 Sources for the interview questions

Question selection was cross-referenced against publicly published 2026 SSL/TLS interview question sets, then rewritten and deepened:

Every error string in this module was produced by running the failure against a live local server — nine failure scenarios against curl 8.x, Python 3.12 ssl, Node 22, Go 1.2x and OpenSSL 3.0.13 — rather than copied from documentation. Two findings came from that testing rather than recall: that untrusted-CA and incomplete-chain produce byte-identical errors in every client tested, and that clients disagree about which fault to report when more than one gate fails (curl reported expiry where Python reported hostname mismatch on the same connection).

Standards were verified against primary sources: RFC 5280 §6, RFC 9525, RFC 5280 §4.2.1.12 and the OpenSSL 3.x manual pages.

Answers were rewritten and deepened rather than reproduced — published versions are usually correct but shallow, and the added operational detail is what actually differentiates a candidate in the room.

Spotted a mistake or want something added? Send me a note.