The client prints Verification: OK, but the HTTPS request still fails. That is possible with mutual TLS: the client can trust the server while the server rejects the client certificate.
I tested a TLS 1.3 service that requires a client identity. The server certificate stayed the same while I tried no client certificate, a certificate signed by the wrong CA, and a certificate issued for the wrong purpose. Each attempt reached the listener and verified its server certificate. None received HTTP 200.
The useful question is which side rejected which certificate. A green server-verification line answers only half of that question.
Reuse the server lab, then add a separate client CA
Complete the namespace and certificate setup in the SNI, hostname, and chain lab first. This article uses its plt-client and plt-server namespaces, the app.test server certificate, the server intermediate, and the unrelated other-root CA.
Run all commands from that same lab directory on the Linux host. The Python listeners are not needed for this article; the new listener uses port 9443. Tested with: kernel 7.0.0-38-generic, OpenSSL 3.5.5, TLS 1.3.
The trust directions are deliberately separate:
Client verifies server: app.test → Server Intermediate → Server Root
Server verifies client: client-good → Client RootThe client’s -CAfile pki/server-root.crt validates the server. The server’s -CAfile pki/client-root.crt validates clients. Confusing those two stores can produce a failure that looks like a missing chain.
Save this as client-pki.sh and run bash client-pki.sh. It refuses to replace existing client keys. These are short-lived lab identities.
#!/usr/bin/env bash
set -euo pipefail
umask 077
for file in client-root.key client-good.key client-rogue.key client-wrong-purpose.key; do
if [[ -e "pki/$file" ]]; then
echo "Refusing existing lab key: $file" >&2
exit 1
fi
done
openssl genpkey -algorithm EC -pkeyopt ec_paramgen_curve:P-256 -out pki/client-root.key
openssl req -new -x509 -key pki/client-root.key -sha256 -days 14 \
-subj '/CN=PingLabz Lab Client Root' -addext 'basicConstraints=critical,CA:TRUE' \
-addext 'keyUsage=critical,keyCertSign,cRLSign' -out pki/client-root.crt
client() {
local name="$1" ca="$2" purpose="$3" serial="$4"
openssl genpkey -algorithm EC -pkeyopt ec_paramgen_curve:P-256 -out "pki/$name.key"
openssl req -new -key "pki/$name.key" -subj "/CN=$name" -out "pki/$name.csr"
cat > "pki/$name.ext" <<EOF
basicConstraints=critical,CA:FALSE
keyUsage=critical,digitalSignature
extendedKeyUsage=$purpose
subjectKeyIdentifier=hash
authorityKeyIdentifier=keyid:always,issuer
EOF
openssl x509 -req -in "pki/$name.csr" -CA "pki/$ca.crt" -CAkey "pki/$ca.key" \
-set_serial "$serial" -days 5 -sha256 -extfile "pki/$name.ext" -out "pki/$name.crt"
}
client client-good client-root clientAuth 201
client client-rogue other-root clientAuth 202
client client-wrong-purpose client-root serverAuth 203
openssl verify -CAfile pki/client-root.crt -purpose sslclient pki/client-good.crtThe script creates three leaf certificates:
client-good: signed by the Client Root, with theclientAuthextended key usage.client-rogue: signed by the unrelated Other Root, also withclientAuth.client-wrong-purpose: signed by the Client Root, but withserverAuthinstead ofclientAuth.
The last two let us change trust and purpose independently. Each has its own matching private key. A key mismatch would be a different test.
Require and enforce client authentication
In a lab-host terminal, start this diagnostic service from the lab directory:
sudo ip netns exec plt-server openssl s_server \
-accept 192.0.2.20:9443 -tls1_3 \
-cert pki/app.crt -key pki/app.key \
-cert_chain pki/server-intermediate.crt \
-CAfile pki/client-root.crt -no-CApath -no-CAstore \
-Verify 1 -verify_return_error -www -stateThe capitalization matters. Uppercase -Verify requires a client certificate. Lowercase -verify requests one but allows a client that supplies none. -verify_return_error makes verification failures reject the connection. Those behaviors are documented in the OpenSSL s_server reference.
Here 1 is the verification depth for the simple client leaf-to-root chain.
-cert_chain supplies the server intermediate to clients; it does not choose which client CA the server trusts. -www supplies a diagnostic HTTP response so the test has an application result to check.
Prove that the good client reaches the application
From a second lab-host terminal, run:
printf 'GET / HTTP/1.0\r\nHost: app.test\r\nConnection: close\r\n\r\n' |
sudo ip netns exec plt-client timeout 12s openssl s_client \
-connect 192.0.2.20:9443 -tls1_3 \
-servername app.test -verify_hostname app.test \
-CAfile pki/server-root.crt -no-CApath -no-CAstore \
-verify_return_error -showcerts -state -ign_eof \
-cert pki/client-good.crt -key pki/client-good.key
echo "s_client exit: $?"The client verified the server chain and received HTTP/1.0 200 ok. Its exit status was 0. In the server terminal, the relevant verification lines were:
depth=1 CN=PingLabz Lab Client Root
verify return:1
depth=0 CN=client-good
verify return:1Those server-side lines describe the client’s chain. They are different from the Server Root and app.test lines printed on the client.
Keep the HTTP request in these tests. In TLS 1.3, the client may finish processing the server certificate before it reads the server’s later rejection of client authentication. -ign_eof lets it keep reading after the piped request ends; the timeout bounds that wait.
Fault one: supply no client certificate
Repeat the client command, removing both -cert pki/client-good.crt and -key pki/client-good.key. Keep strict server verification and every other option.
The client still printed:
Verification: OK
Verify return code: 0 (ok)Then it received:
tlsv13 alert certificate required
SSL alert number 116
s_client exit: 1The server logged peer did not return a certificate. No HTTP 200 arrived. TCP reachability and server trust were both intact; the required client identity was missing.
Repair this case by supplying the good certificate and its matching key. The exact error wording can differ between TLS versions and implementations, so retain the peer log and final application result rather than checking only one string.
Fault two: send a client signed by the wrong CA
Repeat the baseline, changing only the last line to:
-cert pki/client-rogue.crt -key pki/client-rogue.keyThe client received tlsv1 alert unknown ca, alert number 48, and exited 1 without an HTTP response. The tlsv1 spelling is part of OpenSSL’s diagnostic text here; the connection was pinned to TLS 1.3.
The server gave the more useful chain detail:
depth=0 CN=client-rogue
verify error:num=20:unable to get local issuer certificateThis client certificate contains clientAuth, but it was issued by Other Root. The service trusts Client Root. The fault is on the service’s client-validation side, even though the client correctly trusts the service’s server certificate.
Check the public certificate and reproduce that validation on the lab host:
openssl x509 -in pki/client-rogue.crt -noout \
-subject -issuer -ext extendedKeyUsage
openssl verify -CAfile pki/client-root.crt \
-purpose sslclient pki/client-rogue.crtThe offline check also failed with error 20 at depth 0. In this lab the client is directly signed by an unrelated root, so there is no missing client intermediate to add. With a longer real client chain, inspect the issuer path and what the client sends before changing the server trust store.
Use a certificate issued under the service’s intended client CA. Adding an unrelated CA merely to silence the error changes who the service accepts.
Fault three: trust the issuer, reject the purpose
Now repeat the baseline with:
-cert pki/client-wrong-purpose.crt -key pki/client-wrong-purpose.keyThis certificate is signed by the trusted Client Root. Its extended key usage allows server authentication, not client authentication.
The server logged:
depth=0 CN=client-wrong-purpose
verify error:num=26:unsuitable certificate purposeThe client received ssl/tls alert unsupported certificate, alert number 43, exited 1, and received no HTTP 200. Trusting the issuer did not make this leaf suitable for client authentication.
Check purpose offline and compare it with the good leaf:
openssl x509 -in pki/client-wrong-purpose.crt -noout \
-subject -issuer -ext extendedKeyUsage
openssl verify -CAfile pki/client-root.crt \
-purpose sslclient pki/client-wrong-purpose.crt
openssl verify -CAfile pki/client-root.crt \
-purpose sslclient pki/client-good.crtThe wrong-purpose check failed with error 26. The good-client check returned pki/client-good.crt: OK. The OpenSSL verification options describe purpose checking.
Repair the identity by using a certificate issued for client authentication. Editing a PEM label or copying a private key cannot change a signed certificate’s extensions.
Verify recovery and keep both sides of the evidence
Run the original good-client command again. In my final check, the server accepted client-good, the client verified app.test, HTTP 200 arrived, and the process exited 0. I did not change the listener’s trust store between the three faults and recovery.
For each of the five cases—good, missing, wrong CA, wrong purpose, good again—I saved client stdout/stderr, the server verification log, and a separate capture on the server namespace’s eth0. Each capture contained a TCP SYN and SYN-ACK. Saved packet counts matched tcpdump’s captured and filter-received counts, with zero kernel drops.
An undecrypted TLS 1.3 capture cannot show the client certificate or the later alert reason. The OpenSSL logs provide that evidence. If you capture this test yourself, use:
sudo ip netns exec plt-server tcpdump --immediate-mode -U -n \
-i eth0 -s 0 -w mtls.pcap 'tcp port 9443'Start it before one probe and press Ctrl-C afterward. Compare the capture with that probe’s client output and the corresponding server log, not a different connection’s successful verification line.
Put the rejection on the correct side
A missing client identity, an untrusted issuer, and an unsuitable purpose require different repairs. All three can happen after the client has successfully verified the server. Look for the requested client identity, the server’s validation result, the returned alert, and whether an application response actually arrived.
For broader API authorization, continue with REST API security: tokens, TLS, and least privilege. TLS client authentication establishes the identity; the API still decides which actions that identity may perform.
If the first failure is the server’s name or chain, go back to the server-verification lab. If there is no TCP exchange at all, use the route, listener, and firewall checks.