← Debugging Guides

阅读中文版本

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.

advanceddebuggingmtlsnginxtls

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 -dates

Interpretation

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 -issuer

Interpretation

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.