How-to guide · Harden and secure

How to set up mutual TLS (nginx and curl)

In one sentence

You will have an nginx endpoint that accepts only clients holding a certificate from your own client CA, lets in only the clients you name, and is tested with curl.

Task
Harden and secure
Platforms
  • nginx
  • Linux
  • Windows
Who it is for
Level
Practitioner · 6 steps
On this page
  1. Before you start
  2. Steps
  3. 1. Create a client CA
  4. 2. Issue a client certificate
  5. 3. Require client certificates in nginx
  6. 4. Call the endpoint with curl
  7. 5. Give the certificate to other clients
  8. 6. Plan revocation and renewal
  9. Check it worked
  10. If something goes wrong
  11. Why this works
  12. Go deeper

Before you start

  • nginx already serving HTTPS with a valid server certificate (see How to install a certificate in nginx).
  • OpenSSL 3.x and curl on a Linux machine, macOS or WSL.
  • A private CA for clients only. This guide builds a small one for testing; for production, use a managed private CA or one like step-ca, designed as in A CA of your own.

Why this matters: in mutual TLS the client proves its identity with a client certificate before any request reaches your application. Public CAs are leaving this job: Let's Encrypt certificates no longer include the TLS client authentication purpose (from 11 February 2026 for the default profile, and 8 July 2026 for the last opt-in profile), and from 15 March 2027, TLS certificates that chain to the Chrome Root Store must list only the TLS server authentication purpose. Client certificates now come from a CA you control.

Steps

1. Create a client CA

Keep it separate from anything that issues server certificates, so trusting it for clients trusts nothing else:

openssl req -x509 -newkey ec -pkeyopt ec_paramgen_curve:P-256 -nodes \
  -keyout client-ca.key -out client-ca.crt -days 365 \
  -subj "/O=Example Corp/CN=Example Client CA" \
  -addext "basicConstraints=critical,CA:TRUE,pathlen:0" \
  -addext "keyUsage=critical,keyCertSign,cRLSign"

In Git Bash on Windows, run export MSYS_NO_PATHCONV=1 first, or the /O=… subject is turned into a Windows path. The key here sits in a file, which is fine for a test. A production client CA keeps its key in an HSM or a managed service.

2. Issue a client certificate

The client creates its own key and a CSR, so the private key never leaves it:

openssl req -new -newkey ec -pkeyopt ec_paramgen_curve:P-256 -nodes \
  -keyout client.key -out client.csr -subj "/O=Example Corp/CN=build-agent-07"

The CA signs it with the extensions a client certificate needs, from a file client.ext:

basicConstraints=critical,CA:FALSE
keyUsage=critical,digitalSignature
extendedKeyUsage=clientAuth
subjectAltName=URI:spiffe://example.org/ci/build-agent-07
openssl x509 -req -in client.csr -CA client-ca.crt -CAkey client-ca.key \
  -CAcreateserial -days 30 -extfile client.ext -out client.crt
openssl verify -CAfile client-ca.crt -purpose sslclient client.crt srvonly.crt
client.crt: OK
O=Example Corp, CN=laptop-12
error 26 at 0 depth lookup: unsuitable certificate purpose
error srvonly.crt: verification failed

srvonly.crt comes from the same CA but has only the serverAuth purpose, so it cannot log in as a client (OpenSSL 3.0.7 and older word this unsupported certificate purpose). A short lifetime, 30 days here, limits the damage of a lost key; automate renewal as in step 6.

3. Require client certificates in nginx

Add the client CA and an allow-list to the HTTPS server. This is the configuration we tested:

http {
    map $ssl_client_s_dn $mtls_allowed {
        default                               0;
        "CN=build-agent-07,O=Example Corp"    1;
    }

    server {
        listen 443 ssl;
        server_name api.example.com;
        ssl_certificate        /etc/nginx/tls/api.example.com.fullchain.pem;
        ssl_certificate_key    /etc/nginx/tls/api.example.com.key;

        ssl_client_certificate /etc/nginx/tls/client-ca.crt;   # trust anchor for clients
        ssl_verify_client      on;
        ssl_verify_depth       2;
        ssl_crl                /etc/nginx/tls/client-ca.crl;

        location / {
            if ($mtls_allowed = 0) { return 403; }
            proxy_set_header X-Client-DN     $ssl_client_s_dn;
            proxy_set_header X-Client-Verify $ssl_client_verify;
            proxy_pass http://app;
        }
    }
}
  • ssl_client_certificate is the only CA nginx trusts for clients, and nginx sends its name to clients in the handshake.
  • ssl_verify_client on rejects requests without a valid certificate. With optional, your application must check $ssl_client_verify itself.
  • The map turns authentication into authorisation: a valid certificate from your CA is not enough; the client must also be on the list. nginx writes the DN in RFC 2253 order, most specific part first (CN=…,O=…), so copy the exact string from a test request rather than typing it.
  • proxy_set_header overwrites any X-Client-* headers a client sends itself. The application behind nginx must accept these headers only from nginx.

Test and reload with sudo nginx -t && sudo systemctl reload nginx.

4. Call the endpoint with curl

With curl built on OpenSSL, as on Linux and macOS, pass the certificate and key:

curl https://api.example.com/                                          # no certificate
curl --cert client.crt --key client.key https://api.example.com/        # with certificate

Real results from our lab, where a test location answered with return 200 "Hello $ssl_client_s_dn (verify: $ssl_client_verify)\n"; instead of proxy_pass:

<head><title>400 No required SSL certificate was sent</title></head>
Hello CN=build-agent-07,O=Example Corp (verify: SUCCESS)

If the client certificate was issued by an intermediate CA, put the intermediate after the leaf in the --cert file, so the server receives the whole chain.

5. Give the certificate to other clients

Browsers, Windows and many Java tools import a PKCS #12 file holding the certificate, its key and the CA:

openssl pkcs12 -export -in client.crt -inkey client.key -certfile client-ca.crt -out client.p12

Choose a strong export password. If an older system refuses the file, add -legacy to use the older encryption it understands. The curl.exe built into Windows uses Schannel, which reads client certificates from the Windows certificate store: import the .p12 there, then pass --cert "CurrentUser\MY\<thumbprint>", as described in the curl manual.

6. Plan revocation and renewal

To block a certificate before it expires, revoke it at the CA, publish a new CRL and reload nginx. In our lab, a revoked client got 400 and the error log said client SSL certificate verify error: (23:certificate revoked).

A CRL has a nextUpdate date. When it passes, nginx rejects every client: our lab logged (12:CRL has expired) for a perfectly good certificate. Generate CRLs on a schedule, well before nextUpdate, and monitor that date like a certificate expiry. Many teams avoid CRLs by issuing client certificates that live only hours or days, renewed automatically by a private ACME or SCEP service.

Check it worked

  1. A request without a certificate gets 400 No required SSL certificate was sent.
  2. A certificate from another CA gets 400 The SSL certificate error; the log shows (21:unable to verify the first certificate).
  3. A valid certificate for a client not on the list gets 403.
  4. The named client gets your application’s answer.

Paste the client certificate into the decoder and check its Extended Key Usage, SAN and issuer:

Try it: Certificate decoderOpen the full tool

Runs in your browser. Nothing you paste is sent anywhere.

PEM text, several PEM blocks, or a .pem, .crt, .cer or .der file (drop it on the box). Certificates are public, so they are safe to paste.

If something goes wrong

nginx logs the reason for every rejected client certificate at the info level. Set error_log /var/log/nginx/error.log info; while you debug.

  • client sent no required SSL certificate: the client sent nothing. It used the wrong files, or it skipped a certificate whose issuer was not in the server’s list of acceptable CAs.
  • (21:unable to verify the first certificate) or (20:unable to get local issuer certificate): the certificate comes from a CA nginx does not trust, or an intermediate is missing. Servers other than nginx often end the handshake with tlsv1 alert unknown ca instead.
  • (26:unsuitable certificate purpose) (unsupported certificate purpose in OpenSSL 3.0.7 and older): the certificate lacks clientAuth. Re-issue it from your client CA.
  • (12:CRL has expired) for every client: publish a fresh CRL and reload.
  • curl fails before sending anything with unable to get local issuer certificate: that is the client distrusting the server. Add --cacert for a private server CA.
  • The backend never sees the certificate: a load balancer in front terminates TLS. Configure mTLS there, or pass the identity on as above.

Why this works

In the TLS handshake, the server sends a CertificateRequest listing the CAs it accepts. The client answers with its certificate and a signature over the handshake made with its private key, which proves it holds that key (mutual TLS). nginx then validates the chain to your client CA, the clientAuth purpose and the CRL, exactly as a browser validates a server (revocation). Using your own CA means you decide who gets a certificate, which is the policy question a private CA exists to answer.

Go deeper

Early edition. Checked against the standards; an independent expert review is in progress. Found a mistake? Tell us.

Last reviewed Edition 0.9