tlsv13 alert certificate required: mTLS client cert fix
If your client dies with error:0A00045C:SSL routines::tlsv13 alert certificate required (curl exits 56) while the same URL loads fine in a browser and every certificate in the chain verifies, you are looking at mutual TLS: the server demanded a client certificate and your client sent an empty one. The failure has nothing to do with the CA bundle you have been editing. It is the second half of mTLS — the half that authenticates you to the server — and it fails on the wire in a way that produces a reassuring Verify return code: 0 (ok) right above the error, which is why so many of these tickets end with someone reinstalling ca-certificates.
I rebuilt this failure end to end on 2026-09-17 with no root and no external service: a local OpenSSL certificate authority, an OpenSSL s_server that requires a client certificate, and four clients (openssl, curl, Node as a client, Node as a server). Every string quoted below was produced by that rig, on OpenSSL 3.5.6, curl 8.14.1 and Node v26.5.1. Two findings are worth the read: the alert number is not stable across protocol versions — TLS 1.3 answers with alert 116 (certificate_required) and TLS 1.2 answers with alert 40 (handshake_failure) for the identical misconfiguration — and the server's own log line, peer did not return a certificate, is the one string that names the cause outright.
TL;DR
- Read the alert number first.
SSL alert number 116/tlsv13 alert certificate requiredmeans the server sent a CertificateRequest and the client sent zero certificates. Alert 40 (handshake failure) on the same endpoint almost always means the same thing over TLS 1.2 — measured, not guessed:error:0A000410:SSL routines:ssl3_read_bytes:ssl/tls alert handshake failure:…SSL alert number 40. - A passing verify line does not clear you. In the TLS 1.3 run, openssl printed
Verify return code: 0 (ok)and then the alert. That line describes the server's certificate against your CA — it says nothing about whether you presented one. - Your CA bundle is usually innocent. When trust is genuinely the problem the error is
verify error:num=19:self-signed certificate in certificate chain, a different string with a different fix. Two different failures, two different messages. - The three real fixes: openssl
-cert client.crt -key client.key; curl--cert client.crt --key client.key; Node'shttps.request({ cert, key }). Each turns the failure into a 200 in the tables below. - The server side names it: OpenSSL logs
error:0A0000C7:SSL routines:tls_process_client_certificate:peer did not return a certificate; a Node server emitstlsClientErrorwithcode=ERR_SSL_PEER_DID_NOT_RETURN_A_CERTIFICATE. If you own the server, look there before touching the client. - Optional is not required. A server asking for a client certificate (
SSL_VERIFY_PEER) accepts a client that sends none; onlySSL_VERIFY_FAIL_IF_NO_PEER_CERTproduces these alerts. Measured: same client, same certificates, one flag apart, 200 versus alert 116. - TLS 1.3 hides the failure behind a completed handshake. The client finishes the handshake, then reads the alert. Nothing at the HTTP layer — no retry middleware, no proxy buffer setting — can fix a handshake-time identity problem.
On this page
- The exact strings, four clients, one misconfiguration
- Building the rig: a server that requires a client certificate
- Why this is not a CA trust problem
- Fixing the client: openssl, curl, Node.js
- What the server logs when it is your fault
- The TLS 1.3 trap: the alert arrives after the handshake
- Decision table
- Related work on this blog
The exact strings, four clients, one misconfiguration
Everything below is the verbatim output of one server configuration (client certificate required, verified against a local CA) probed by four different clients. The leading hex handle in the OpenSSL lines (20D03EA7EDFC0000) is a per-process error-queue identifier; it changes on every run, so match on the text after the first colon, never on the whole line.
| Client | What it prints | Exit / status |
|---|---|---|
openssl s_client, TLS 1.3, no client cert |
…:error:0A00045C:SSL routines:ssl3_read_bytes:tlsv13 alert certificate required:../ssl/record/rec_layer_s3.c:918:SSL alert number 116 |
alert on stderr, Verify return code: 0 (ok) still printed |
openssl s_client -tls1_2, no client cert |
…:error:0A000410:SSL routines:ssl3_read_bytes:ssl/tls alert handshake failure:../ssl/record/rec_layer_s3.c:918:SSL alert number 40 |
alert 40, same cause |
| curl, TLS 1.3, no client cert | curl: (56) OpenSSL SSL_read: OpenSSL/3.5.6: error:0A00045C:SSL routines::tlsv13 alert certificate required, errno 0 |
exit 56, HTTP code never assigned |
| curl, TLS 1.2 forced, no client cert | curl: (35) TLS connect error: error:0A000410:SSL routines::ssl/tls alert handshake failure |
exit 35 |
Node.js https.request, no client cert |
code=ERR_SSL_TLSV13_ALERT_CERTIFICATE_REQUIRED, message ending …tlsv13 alert certificate required:…SSL alert number 116 |
error event, no response object |
Two things in that table cause most of the wasted hours. The first is the exit code: 56 and 35 are both "something failed during the TLS exchange" and neither is documented as "you forgot a client certificate", so a retry-on-5xx middleware happily retries a handshake that can never succeed. The second is that curl reports TLS 1.3 as a read error (56) and TLS 1.2 as a connect error (35). Same configuration, different code, purely because of where in the state machine the alert lands.
Notice also what is absent from every one of those messages: the words certificate authority, CA bundle, or trust. If you are searching this error and landing on CA-bundle advice, you are one error string away from the wrong fix.
Building the rig: a server that requires a client certificate
You do not need a staging environment or a second host to reproduce this. One CA, one key pair for the server, one for the client, and OpenSSL's built-in test server are enough. These are the commands I ran, in order, on this host; openssl version reports OpenSSL 3.5.6 7 Apr 2026, and the whole setup takes a few seconds.
mkdir -p certs && cd certs
# 1. A throwaway CA (30 days is plenty for a lab)
openssl req -x509 -newkey rsa:2048 -nodes -keyout ca.key -out ca.crt -days 30 \
-subj "/CN=C2CZ Lab Root CA"
# 2. Server key pair, with a SAN so modern clients accept it
openssl req -newkey rsa:2048 -nodes -keyout server.key -out server.csr \
-subj "/CN=localhost"
printf 'subjectAltName=DNS:localhost,IP:127.0.0.1\nextendedKeyUsage=serverAuth\n' > server.ext
openssl x509 -req -in server.csr -CA ca.crt -CAkey ca.key -CAcreateserial \
-out server.crt -days 30 -extfile server.ext
# 3. Client key pair — the part everyone forgets, with clientAuth EKU
openssl req -newkey rsa:2048 -nodes -keyout client.key -out client.csr \
-subj "/CN=c2cz-lab-client"
printf 'extendedKeyUsage=clientAuth\n' > client.ext
openssl x509 -req -in client.csr -CA ca.crt -CAkey ca.key -CAcreateserial \
-out client.crt -days 30 -extfile client.ext
The extendedKeyUsage lines are not decoration. A certificate without clientAuth in its EKU will be presented by a well-behaved client and then rejected by a strict server, which produces a different error than the one in this article. Issue client certificates with the right EKU and the failure mode stays unambiguous.
Now start a server that requires a client certificate. The distinction between the two flags matters and is measurable: -Verify sets SSL_VERIFY_PEER | SSL_VERIFY_FAIL_IF_NO_PEER_CERT, while lowercase -verify requests a certificate without requiring one (OpenSSL s_server documentation; the two-server comparison below is my own measurement, ports 19443 and 19444 because 8443 on this host was already occupied).
# Server A — client certificate REQUIRED (this is what alert 116 / 40 means)
openssl s_server -accept 19443 -cert server.crt -key server.key -CAfile ca.crt \
-Verify 2 -www
# Its first line of output is the confirmation you want to see:
# verify depth is 2, must return a certificate
# Server B — client certificate REQUESTED but optional
openssl s_server -accept 19444 -cert server.crt -key server.key -CAfile ca.crt \
-verify 2 -www
Against server A, an openssl client with no certificate produces the alert 116 line from the table above. Against server B, the identical client completes the handshake and reads the page — measured, with Verify return code: 0 (ok) and no alert at all. That one-flag difference is the entire difference between "our clients work" and "our clients are broken" when a load balancer, an ingress controller or a service mesh is reconfigured, and it is worth keeping the two-server rig on hand for exactly that reason.
Why this is not a CA trust problem
Here is the diagnostic detail that resolves most of these tickets in one command. In my failing TLS 1.3 run, the client printed the alert and the trust verdict for the server's certificate:
openssl s_client -connect 127.0.0.1:19443 -CAfile ca.crt -servername localhost </dev/null 2>&1 | \
grep -E "Acceptable client|Verify return code|alert"
# Acceptable client certificate CA names
# Verify return code: 0 (ok)
# 20D03EA7EDFC0000:error:0A00045C:SSL routines:ssl3_read_bytes:tlsv13 alert certificate required:../ssl/record/rec_layer_s3.c:918:SSL alert number 116
Verify return code: 0 (ok) means the server's chain validated against ca.crt. The Acceptable client certificate CA names block above it is the server telling you, in the clear, which authorities it will accept a client certificate from — that list is the CertificateRequest. Both lines together are a complete diagnosis: the server's identity is fine, your CA bundle is fine, and the server is waiting for an identity you did not send.
Contrast that with the genuine trust failure, which I reproduced by dropping the -CAfile flag while keeping the client certificate:
openssl s_client -connect 127.0.0.1:19443 -cert client.crt -key client.key -servername localhost </dev/null
# verify error:num=19:self-signed certificate in certificate chain
# Verify return code: 19 (self-signed certificate in certificate chain)
num=19: self-signed certificate in certificate chain is a different failure with a different fix: the client does not trust what the server presented, so no amount of client-certificate configuration will help. That branch — and the -k-style workarounds that hide it — is covered in self-signed certificate in certificate chain: fix for curl 60. Keep the two apart: num=19 is about the server proving itself to you; alert 116 is about you proving yourself to the server.
Fixing the client: openssl, curl, Node.js
The fix is always the same shape — present a certificate and its private key — and the only thing that differs is the client's flag syntax. openssl first, because it is the client that will tell you the most if it still fails.
openssl s_client -connect 127.0.0.1:19443 -CAfile ca.crt \
-cert client.crt -key client.key -servername localhost -verify_return_error
# ---
# Certificate chain
# 0 s:CN=localhost
# 1 s:CN=C2CZ Lab Root CA
# Acceptable client certificate CA names
# Verify return code: 0 (ok)
With curl, the equivalent is --cert and --key. Measured on curl 8.14.1 against the same server: without them, http_code=000 and exit 56; with them, with-cert http_code=200 ssl_verify=0. Note the success case in the second command below — ssl_verify=0 is curl reporting that the server's chain verified, which is the value you want to see in a health check.
# Fails: exit 56, "tlsv13 alert certificate required"
curl -s -o /dev/null -w 'http_code=%{http_code}\n' --cacert ca.crt https://127.0.0.1:19443/
# Works: exit 0, HTTP 200
curl -s -o /dev/null -w 'http_code=%{http_code} ssl_verify=%{ssl_verify_result}\n' \
--cacert ca.crt --cert client.crt --key client.key https://127.0.0.1:19443/
In production, keep the key out of the command line — a private key passed as a path is fine, a key pasted into a shell history is not. curl reads the key file with whatever permissions you gave it, so chmod 600 client.key and an owner-only directory is the minimum. If your client certificate is in PKCS#12 form, --cert bundle.p12:password works, but a PEM pair plus a secrets manager is easier to rotate.
Node.js is where this error is most often misdiagnosed, because the failure arrives as an error event and never as a response. Pass the certificate and key in the request options, and always provide ca so you are not accidentally running with a relaxed trust check:
import { readFileSync } from 'node:fs';
import https from 'node:https';
const base = {
host: '127.0.0.1',
port: 19443,
path: '/',
method: 'GET',
ca: readFileSync('./certs/ca.crt'),
servername: 'localhost',
timeout: 5000,
};
https
.request(
{
...base,
cert: readFileSync('./certs/client.crt'),
key: readFileSync('./certs/client.key'),
},
(res) => {
console.log('status', res.statusCode);
res.resume();
},
)
.on('timeout', function () {
this.destroy(new Error('socket timeout'));
})
.on('error', (err) => {
// code=ERR_SSL_TLSV13_ALERT_CERTIFICATE_REQUIRED without cert/key
console.error(`code=${err.code} message=${err.message}`);
})
.end();
Run that file twice, once with the cert/key properties and once without, and the difference is unambiguous. My runs on Node v26.5.1 produced [with-client-cert] status=200 bytes=9628 and, in the failing case, code=ERR_SSL_TLSV13_ALERT_CERTIFICATE_REQUIRED with the full OpenSSL message attached. Match on err.code, not on err.message: the code is stable, the message contains an absolute source path from the runtime's own OpenSSL build (../deps/openssl/openssl/ssl/record/rec_layer_s3.c) that will differ between Node releases.
A last client-side gotcha, and a common one in service meshes and sidecar proxies: if the client certificate lives in a file that the process cannot read, some runtimes silently fall back to sending an empty certificate list. You then get alert 116 with a perfectly valid certificate on disk. Before blaming the server, confirm the process can actually open the key: test -r client.key as the service account, not as you.
What the server logs when it is your fault
If you control the server, stop debugging the client for a moment and read the server log. OpenSSL's own line names the cause directly, and it appears in every denial I generated:
# openssl s_server stderr, one line per refused connection
20608C4457ED0000:error:0A0000C7:SSL routines:tls_process_client_certificate:peer did not return a certificate:../ssl/statem/statem_srvr.c:3751:
peer did not return a certificate is the sentence to search for in your logs. It is emitted by the server's tls_process_client_certificate handler at the moment it unwraps an empty certificate message, and it is the same string whether the client negotiated TLS 1.2 or TLS 1.3 — the two entries I collected from one server log are identical, one per denied connection. A Node.js server surfaces the same condition through a different hook:
server.on('tlsClientError', (err) => {
// code=ERR_SSL_PEER_DID_NOT_RETURN_A_CERTIFICATE
// message=...error:0A0000C7:SSL routines:tls_process_client_certificate:peer did not return a certificate...
console.warn(`tls client rejected code=${err.code}`);
});
The Node server in that run was configured with requestCert: true and rejectUnauthorized: true, and the probe went as follows: a curl call with no client certificate terminated in tlsClientError and never reached the request handler; the same call with --cert/--key returned mtls ok cn=c2cz-lab-client with HTTP 200. The tlsClientError hook is the one place where a rejected handshake is visible at all — without it, a server with requestCert enabled looks like it is receiving no traffic.
Documented configurations for the common reverse proxies follow the same optional/required split, and the flag names are worth knowing before you change one by accident: nginx's ssl_verify_client takes on, off, optional and optional_no_ca; HAProxy's verify takes none, optional and required; Envoy's require_client_certificate is a boolean whose default is false. I could not execute these on this container (no nginx, HAProxy or Envoy installed, and no root to install them), so treat those directive values as documentation-sourced; the alert numbers, the client messages and the OpenSSL log line above are measured, and they do not depend on which proxy is in front.
The TLS 1.3 trap: the alert arrives after the handshake
This is the part that makes the error so confusing on modern stacks. Ask openssl to show its state machine and the failure does not happen where you expect:
openssl s_client -state -connect 127.0.0.1:19443 -CAfile ca.crt -servername localhost </dev/null
# SSL_connect:SSLv3/TLS read server certificate request
# SSL_connect:TLSv1.3 read encrypted extensions
# SSL_connect:SSLv3/TLS read finished
# SSL_connect:SSLv3/TLS write client certificate <-- empty list, sent anyway
# SSL_connect:SSLv3/TLS write finished
# ...then: tlsv13 alert certificate required (SSL alert number 116)
The client reads the server's CertificateRequest, reads the server's Finished, writes an empty Certificate message, writes its own Finished, and only then receives the alert. In TLS 1.3 the certificate exchange is encrypted, and the client is permitted to send an empty certificate — the server decides afterwards. That is why the OpenSSL line says SSL_read and not SSL_connect, why curl reports error 56 (a read failure) rather than 35 (a connect failure), and why a completed handshake in your logs is not evidence that the handshake was accepted.
The practical consequences are worth writing on a sticky note. First, connection pooling and keep-alive are irrelevant: the connection never becomes usable, so an HTTP-level retry cannot help and will simply burn the same TLS failure again. Second, a monitoring check that greps for "handshake failure" will miss the TLS 1.3 variant entirely — match on alert certificate required, alert number 116 and peer did not return a certificate instead. Third, in TLS 1.2 the same misconfiguration fails earlier and louder, with alert 40 and curl exit 35, so if a client that worked against a TLS 1.2 endpoint broke the day the endpoint was upgraded to TLS 1.3, the certificate requirement may have been there all along — a server that only requested a client certificate on TLS 1.2 can start to look enforced once the protocol version changes.
Decision table
Five minutes with this table resolves nearly every instance of the error, in the order the checks should be run.
| Symptom in hand | What it means | Action |
|---|---|---|
tlsv13 alert certificate required, alert 116, curl 56 |
The server requires a client certificate; the client sent none | Add --cert/--key (curl), -cert/-key (openssl), cert/key (Node) |
alert 40, handshake failure, curl 35 on the same endpoint |
Same condition over TLS 1.2 | Fix the client identity, not the cipher list |
Verify return code: 0 (ok) printed with the alert |
The server's chain is fine; only your identity is missing | Stop editing the CA bundle |
verify error:num=19:self-signed certificate in certificate chain |
Trust failure, unrelated to mTLS | Fix the CA chain on the client |
Server log: peer did not return a certificate |
The server saw an empty certificate message | Confirm the client certificate and key are both loaded and readable |
| Handshake completes in the log, then the connection dies | Expected on TLS 1.3 | Log the alert, not the handshake state |
One operational note to close on. Because the alert is sent after a completed handshake, an mTLS rollout that is monitored by handshake success rate will look healthy right up to the moment traffic is served by the new configuration — and a certificate that expires or is rotated without updating the client produces exactly the same alert 116 as a client that never had one. Alert on alert certificate required and on days-to-expiry of the client certificate, not on handshake completion.
Related work on this blog
- self-signed certificate in certificate chain: fix for curl 60 — the trust branch that looks similar and is not:
num=19versus alert 116, and how to tell them apart in one command. - Cloudflare Error 1020 Access Denied: fix — the edge-side access control that fires when a request reaches the CDN without the identity the origin policy expects.
- curl (52) Empty reply from server: causes and fix — the other curl failure where the transport tells you nothing and the server-side log tells you everything.
All outputs quoted above were produced by execution on 2026-09-17 on this host: Linux aarch64, kernel 6.17.0-1019-oracle, OpenSSL 3.5.6 (7 Apr 2026), curl 8.14.1 with OpenSSL/3.5.6, Node.js v26.5.1, no root. The rig was a throwaway CA, a server key pair with SAN DNS:localhost, IP:127.0.0.1, a client key pair with TLS Web Client Authentication EKU, an openssl s_server requiring a client certificate on port 19443 and one requesting it optionally on port 19444, and a Node.js https server with requestCert: true/rejectUnauthorized: true on port 19445. Every error string, alert number, exit code, HTTP status and byte count in this article comes from those runs; port 8443 was deliberately avoided because another service on this host already owns it. The -verify versus -Verify semantics (SSL_VERIFY_PEER versus SSL_VERIFY_PEER|SSL_VERIFY_FAIL_IF_NO_PEER_CERT), the extendedKeyUsage requirement for client certificates, and the nginx, HAProxy and Envoy directive values are cited from their respective official documentation and are labelled as such in the text.