mTLS — 400 No Required SSL Certificate Was Sent
Diagnose mTLS failures from nginx and load balancers: missing client cert, wrong CA trust, expiry, and depth issues, verified with openssl s_client.
Symptoms
- nginx returns 400 No required SSL certificate was sent, or 400 The SSL certificate error for API calls that work over plain TLS
- The load balancer closes the connection during handshake with a certificate_required alert
- Some client environments pass and others fail with identical credentials
Possible causes
- The client does not present a certificate at all (not configured for this context or path)
- The server trusts a different CA than the one that issued the client certificate
- The client certificate is expired, or the chain is incomplete and exceeds the verification depth
- SNI or path-based mTLS enforcement routes the request to a server block that requires a cert the client does not use
Diagnosis steps
Inspect the handshake with openssl
openssl s_client -connect api.example.com:443 -servername api.example.com -cert client.pem -key client.key -CAfile ca.pem </dev/null 2>&1 | grep -E "Verify return|depth|error|ACCEPTABLE|OK"Interpretation
The handshake output shows whether the server requested a cert, accepted the chain, and which CA it announced as acceptable.
Check the server policy
grep -Rn "ssl_verify_client\|ssl_client_certificate" /etc/nginx/ && openssl x509 -in client.pem -noout -issuer -datesInterpretation
ssl_verify_client optional vs on, and the trust chain of ssl_client_certificate versus the client issuer, is the policy mismatch test.
Compare working and failing clients
openssl x509 -in working.pem -noout -issuer; openssl x509 -in failing.pem -noout -issuerInterpretation
Different issuers between environments means one CA is trusted and the other is not — a provisioning problem, not a handshake problem.
Root cause
Mutual TLS requires the client to present a certificate the server trusts. The failure occurs at different layers depending on the gap: no certificate sent (client misconfiguration), untrusted issuer (CA mismatch), expired credential, or incomplete chain exceeding verify depth — each producing a distinct diagnostic signature.
Fix
Configure the client to present its certificate and key on the mTLS context, align the server trust to the issuing CA, rotate expiring client certs with monitoring, send the full chain, and set ssl_verify_depth to cover it. For partial enforcement, separate server blocks or optional verification with application-level checks.
Risk note
ssl_verify_client optional without app-level checks silently accepts certless requests. Enforce in one place, deliberately.
Verify the fix
The openssl handshake completes with Verify return code 0, the API call succeeds, and an unauthenticated client is rejected with the documented error.
Symptom walkthrough
The nginx message is admirably specific:
400 No required SSL certificate was sent
versus its sibling for trust problems:
400 The SSL certificate error
The first means the server asked (ssl_verify_client on) and the client presented nothing. The second means a cert arrived but failed verification — wrong CA, expiry, or depth. Drive the handshake yourself to see the negotiation the way the server does:
openssl s_client -connect api.example.com:443 \
-servername api.example.com \
-cert client.pem -key client.key \
</dev/null 2>&1 | grep -E "Verify|depth|error"
# Verify return code: 0 (ok) <- success
# verify error: self-signed certificate in certificate chain
# verify error: certificate has expired
Fix in practice
Align the three parts: client presentation, server trust, and chain depth.
server {
listen 443 ssl;
server_name api.example.com;
ssl_client_certificate /etc/nginx/ca/client-ca.pem;
ssl_verify_client on; # or optional + app-level check
ssl_verify_depth 2; # cover the intermediate chain
}
# verify the client pair against the same CA the server trusts:
openssl verify -CAfile /etc/nginx/ca/client-ca.pem client.pem
openssl s_client -connect api.example.com:443 \
-cert client.pem -key client.key </dev/null 2>&1 | grep Verify
Send the full chain from the client (cert plus intermediate), rotate expiring client certificates on a monitored schedule, and when only some routes require mTLS, split them into a separate server block — ssl_verify_client optional without application-level checks silently accepts certificate-less requests, so enforce in exactly one deliberate place.