Module 07 — Certificate Validation: What a Client Actually Checks
Updated 21 August 2026
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 passport is on the desk. The officer now works through a list, and stops at the first thing that fails:
- Is this document genuine — can I trace its stamp back to a government on my list?
- Is it in date?
- Is it the right person standing here?
- Is it the right kind of document for what you are asking to do?
- 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
| # | Gate | What it rejects |
|---|---|---|
| 1 | Build a path | No route from this certificate to a trusted anchor — missing intermediate, or unknown CA |
| 2 | Verify every signature | A forged or corrupted certificate anywhere in the chain |
| 3 | Check every validity period | Expired or not-yet-valid — at any depth, not just the leaf |
| 4 | Enforce CA constraints | CA:FALSE used as an issuer, pathlen exceeded, name constraints violated |
| 5 | Check the purpose | Key Usage or Extended Key Usage that does not permit TLS server authentication |
| 6 | Match the hostname | A perfectly valid certificate belonging to somebody else |
| 7 | Check revocation | A certificate withdrawn since issuance (Module 09) — and often skipped entirely |
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.
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 *.crtExpired and not-yet-valid certificates need explicit dates, which openssl x509 -req cannot set on OpenSSL 3.0 — so use openssl ca, which can:
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; doneAll 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
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
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 GMTWhat 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
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
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
=== 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 failedWhat 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:
openssl verify -CAfile ca.crt -untrusted chain.pem \
-purpose sslserver -verify_hostname the.host.name leaf.crtAnything 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
The test harness, used by every exercise in this part. Save it once:
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.shA 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 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
cd ~/tls-lab/m07
./try.sh expired.test.crt expired.test✅ Expected result — click to reveal
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:
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:00ZWhat 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
cd ~/tls-lab/m07
./try.sh future.test.crt future.test✅ Expected result — click to reveal
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:
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:00ZWhat 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 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
cd ~/tls-lab/m07
./try.sh good.test.crt wrong.name.test✅ Expected result — click to reveal
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:
GO: tls: failed to verify certificate: x509: certificate is valid for good.test, not wrong.name.testWhat 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:
- The name is genuinely not in the SAN. Reissue with it. openssl x509 -noout -ext subjectAltName shows what is actually there.
- Wildcard depth. *.example.com covers a.example.com but not a.b.example.com, and not bare example.com (Module 03, B3).
- 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.
- 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
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
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
########## 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:
GO: tls: failed to verify certificate: x509: certificate signed by unknown authorityWhat 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:
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 failing → cause 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 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
cd ~/tls-lab/m07
./try.sh selfsigned.test.crt selfsigned.test✅ Expected result — click to reveal
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:
GO: tls: failed to verify certificate: x509: certificate signed by unknown authorityWhat 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:true — turns 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
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
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
=== 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:
GO: tls: failed to verify certificate: x509: certificate specifies an incompatible key usageWhat 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:
openssl verify -CAfile ca.crt -purpose sslserver leaf.crtOne 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:
- The server is not sending its intermediates. Fix on the server: serve fullchain.pem — leaf first, then intermediates, never the root.
- 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:
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:
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
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.
| Cause | curl | Python ssl | Node · OpenSSL code |
|---|---|---|---|
| Expired | certificate has expired | certificate has expired | CERT_HAS_EXPIRED · 10 |
| Not yet valid | certificate is not yet valid | certificate is not yet valid | CERT_NOT_YET_VALID · 9 |
| Hostname mismatch | no alternative certificate subject name matches target host name | Hostname mismatch, certificate is not valid for 'x' | ERR_TLS_CERT_ALTNAME_INVALID · 62 |
| Untrusted CA | unable to get local issuer certificate | unable to get local issuer certificate | UNABLE_TO_VERIFY_LEAF_SIGNATURE · 21 |
| Incomplete chain | unable to get local issuer certificate ⚠️ identical | unable to get local issuer certificate ⚠️ identical | UNABLE_TO_VERIFY_LEAF_SIGNATURE · 21 ⚠️ |
| Self-signed | self-signed certificate | self-signed certificate | DEPTH_ZERO_SELF_SIGNED_CERT · 18 |
| Untrusted root in a real chain | self-signed certificate in certificate chain | self-signed certificate in certificate chain | SELF_SIGNED_CERT_IN_CHAIN · 19 |
| Wrong EKU | unsuitable certificate purpose | unsuitable certificate purpose | INVALID_PURPOSE · 26 |
| No SAN (Module 04) | accepted — CN fallback | accepted — CN fallback | accepted · 0 |
Go, separately — because it behaves differently
| Cause | Go error |
|---|---|
| Expired / not yet valid | x509: certificate has expired or is not yet valid: current time … is after/before … |
| Hostname mismatch | x509: certificate is valid for good.test, not wrong.name.test |
| Untrusted CA · incomplete chain · self-signed | x509: certificate signed by unknown authority — all three, one message |
| Wrong EKU | x509: certificate specifies an incompatible key usage |
| No SAN | x509: certificate relies on legacy Common Name field, use SANs instead — rejected |
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
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
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
########## 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.testWhat 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:
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.pemFour 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
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 |
| Python | verify=False · _create_unverified_context() | verify='/path/ca.crt' · cafile= · REQUESTS_CA_BUNDLE |
| Go | InsecureSkipVerify: true | RootCAs: pool from x509.NewCertPool() |
| Node.js | rejectUnauthorized: false · NODE_TLS_REJECT_UNAUTHORIZED=0 | ca: fs.readFileSync(...) · NODE_EXTRA_CA_CERTS |
| Java | A custom all-trusting TrustManager | keytool -importcert -cacerts · -Djavax.net.ssl.trustStore |
| OpenSSL CLI | (omitting -verify_return_error) | -CAfile ca.crt |
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
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
=== 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 200What 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 4Seven 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
| Habit | Why |
|---|---|
| Validate with all three gates: chain, -purpose sslserver, -verify_hostname | A 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 first | It 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 store | It 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 error | Clients 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 curl | Go 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 otherwise | CAs 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 alerting | The only honest way to confirm your monitoring and error handling actually fire |
| Grep the codebase for -k, verify=False, InsecureSkipVerify, rejectUnauthorized:false | They arrive during incidents and are never removed, because nothing fails afterwards |
| Supply CAs by bundle — --cacert, RootCAs, NODE_EXTRA_CA_CERTS, SSL_CERT_FILE | Explicit, 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 deploying | OpenSSL and curl accept SAN-less certificates via the CN fallback; browsers and Go do not |
E3 · Capstone exercise
Brief. Write a script tlsdiag that takes a hostname and port and reports:
- Whether the connection succeeds at all, and if not which gate failed, by name
- For unable to get local issuer, which of the two causes it is — by counting the certificates sent
- Every fault it can find, not just the first — dates at every depth, SAN presence and match, EKU, chain length
- A one-line verdict with the specific fix, not just the error text
✅ Model answer — attempt it first, then click
#!/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:
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 443The 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 instructions — fix 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
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
| Link | What it is for |
|---|---|
| openssl verify manual | Every error code and its exact text, plus -purpose, -verify_hostname, -show_chain |
| RFC 5280 §6 — Certification Path Validation | The algorithm every client implements. §6.1.3 is the per-certificate checks |
| RFC 9525 — Service Identity in TLS | Hostname matching, definitively. Obsoletes RFC 6125 — cite this one |
| RFC 5280 §4.2.1.12 — Extended Key Usage | What serverAuth means and why a clientAuth-only certificate is refused |
| curl — SSL certificate verification | The page curl's error 60 points at. Explains --cacert versus -k properly |
| Node.js TLS documentation · Go crypto/x509 | The error constants each uses — DEPTH_ZERO_SELF_SIGNED_CERT, CertificateInvalidError and friends |
| Python ssl — security considerations | Why create_default_context() is the right entry point, and what verify=False really turns off |
| openssl ca — -startdate / -enddate | How to generate deliberately expired or future certificates for testing |
| badssl.com | Hosted versions of every failure in this module, if you would rather not build the lab |
The verify codes worth knowing by heart
| Code | Text | What to do |
|---|---|---|
| 0 | ok | — |
| 9 | certificate is not yet valid | Check the client's clock first |
| 10 | certificate has expired | Renew — and check every depth, not just the leaf |
| 18 | self-signed certificate | --cacert, or issue from a real CA |
| 19 | self-signed certificate in chain | A real chain whose root you do not trust |
| 20 | unable to get local issuer certificate | Count the certificates sent — server or client fix |
| 21 | unable to verify the first certificate | Same as 20, seen from s_client |
| 24 | invalid CA certificate | Something in the chain lacks CA:TRUE or keyCertSign |
| 25 | path length constraint exceeded | A pathlen violation (Module 05) |
| 26 | unsuitable certificate purpose | EKU lacks serverAuth. Reissue |
| 47 | permitted subtree violation | A name constraint violation (Module 05) |
| 62 | hostname mismatch | Compare with and without -servername |
The offline alternative
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
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
-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 : YesWhat 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
Validate properly — all three gates
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 gateDiagnose a live endpoint
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 factsBuild test certificates that fail on purpose
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 haveTest with each client
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
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:falseopenssl 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 SNIWhich gate failed · server-fix or client-fix · what the leaf actually says · is it an SNI problem.
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:
- InterviewPrep — Top 25 SSL Certificate Interview Questions and Answers
- ClimbTheLadder — 10 SSL Certificate Interview Questions and Answers
- JavaInUse — Top OpenSSL Interview Questions (2026)
- Devinterview.io — Web Security interview questions for 2026
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.