> ## Content Index
> Fetch the complete content index at: https://www.pinglabz.com/llms.txt
> Use this file to discover other available public pages before exploring further.

# mTLS Client Certificate Rejected? Debug It with OpenSSL
- URL: https://www.pinglabz.com/openssl-mtls-client-certificate-rejected/
- Published: 2026-10-13T06:25:27.000Z
- Updated: 2026-10-13T06:25:27.000Z
- Description: A trusted server can still reject its client. Reproduce three client-certificate failures, compare both sides of TLS, and verify recovery.
- Author: Jaime
- Tags: Network Security, Troubleshooting, Linux, Linux Labs

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](https://www.pinglabz.com/openssl-sni-hostname-certificate-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:

```text
Client verifies server:  app.test → Server Intermediate → Server Root
Server verifies client:  client-good → Client Root
```

The 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.

```bash
#!/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.crt
```

The script creates three leaf certificates:

- `client-good`: signed by the Client Root, with the `clientAuth` extended key usage.
- `client-rogue`: signed by the unrelated Other Root, also with `clientAuth`.
- `client-wrong-purpose`: signed by the Client Root, but with `serverAuth` instead of `clientAuth`.

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:

```bash
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 -state
```

The 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](https://docs.openssl.org/3.5/man1/openssl-s%5Fserver/?ref=pinglabz.com).

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:

```bash
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:

```text
depth=1 CN=PingLabz Lab Client Root
verify return:1
depth=0 CN=client-good
verify return:1
```

Those 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:

```text
Verification: OK
Verify return code: 0 (ok)
```

Then it received:

```text
tlsv13 alert certificate required
SSL alert number 116
s_client exit: 1
```

The 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:

```bash
-cert pki/client-rogue.crt -key pki/client-rogue.key
```

The 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:

```text
depth=0 CN=client-rogue
verify error:num=20:unable to get local issuer certificate
```

This 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:

```bash
openssl x509 -in pki/client-rogue.crt -noout \
  -subject -issuer -ext extendedKeyUsage

openssl verify -CAfile pki/client-root.crt \
  -purpose sslclient pki/client-rogue.crt
```

The 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:

```bash
-cert pki/client-wrong-purpose.crt -key pki/client-wrong-purpose.key
```

This certificate is signed by the trusted Client Root. Its extended key usage allows server authentication, not client authentication.

The server logged:

```text
depth=0 CN=client-wrong-purpose
verify error:num=26:unsuitable certificate purpose
```

The 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:

```bash
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.crt
```

The wrong-purpose check failed with error 26\. The good-client check returned `pki/client-good.crt: OK`. The [OpenSSL verification options](https://docs.openssl.org/3.5/man1/openssl-verification-options/?ref=pinglabz.com) 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:

```bash
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](https://www.pinglabz.com/rest-api-security-network-engineers/). 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](https://www.pinglabz.com/openssl-sni-hostname-certificate-chain-lab/). If there is no TCP exchange at all, use [the route, listener, and firewall checks](https://www.pinglabz.com/firewall-allows-tcp-connection-fails/).