<!-- https://pkizerotohero.com/guides/mutual-tls-setup/ | Markdown mirror of the HTML page | PKI Zero to Hero, by Duyutech BV -->

> Set up mutual TLS: create a client CA and client certificates, require them in nginx, allow only named clients, revoke with a CRL, and test with curl.

Source: https://pkizerotohero.com/guides/mutual-tls-setup/ · Last reviewed: 2026-10-20

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](https://pkizerotohero.com/guides/#task-secure)

Platforms

- nginx
- Linux
- Windows

Who it is for

- [IT ops](https://pkizerotohero.com/start/#itops)
- [Developer](https://pkizerotohero.com/start/#developer)
- [Expert](https://pkizerotohero.com/start/#expert)

Level

Practitioner · 6 steps

## Before you start

- nginx already serving HTTPS with a valid server certificate (see [How to install a certificate in nginx](https://pkizerotohero.com/guides/install-certificate-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](https://pkizerotohero.com/guides/private-ca-step-ca/), designed as in [A CA of your own](https://pkizerotohero.com/handbook/private-ca/).

Why this matters: in [mutual TLS](https://pkizerotohero.com/handbook/glossary/#mutual-tls) the client proves its identity with a [client certificate](https://pkizerotohero.com/handbook/glossary/#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:

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

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

```text
basicConstraints=critical,CA:FALSE
keyUsage=critical,digitalSignature
extendedKeyUsage=clientAuth
subjectAltName=URI:spiffe://example.org/ci/build-agent-07
```

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

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

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

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

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

```bash
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](https://curl.se/docs/manpage.html#-E).

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

*Interactive tool: Certificate decoder (runs in the browser at [/tools/decoder/](https://pkizerotohero.com/tools/decoder/))*

## 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](https://pkizerotohero.com/errors/openssl-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](https://pkizerotohero.com/errors/curl-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](https://pkizerotohero.com/handbook/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](https://pkizerotohero.com/handbook/revocation/)). Using your own CA means you decide who gets a certificate, which is the policy question a [private CA](https://pkizerotohero.com/handbook/private-ca/) exists to answer.

## Go deeper

- [nginx: ngx\_http\_ssl\_module](https://nginx.org/en/docs/http/ngx_http_ssl_module.html): `ssl_verify_client`, `ssl_client_certificate`, `ssl_crl` and the `$ssl_client_*` variables.
- [RFC 9846 section 4.4.2](https://www.rfc-editor.org/rfc/rfc9846#section-4.4.2): the TLS 1.3 CertificateRequest message.
- [RFC 5280 section 4.2.1.12](https://www.rfc-editor.org/rfc/rfc5280#section-4.2.1.12): Extended Key Usage and the `clientAuth` purpose.
- [curl manual: `--cert`](https://curl.se/docs/manpage.html#-E): client certificates with OpenSSL and Schannel builds.
- [OpenSSL: openssl-verify](https://docs.openssl.org/master/man1/openssl-verify/): purpose checks and CRL options.

**Early edition.** Checked against the standards; an independent expert review is in progress. Found a mistake? [Tell us](https://pkizerotohero.com/contact/?topic=content-error&page=/guides/mutual-tls-setup/).

Last reviewed 20 October 2026Edition 0.9

## Keep going

### Learn why it works

- [Chapter 23: Both sides show ID: mutual TLS](https://pkizerotohero.com/handbook/mutual-tls/)
- [Chapter 22: A CA of your own: private two-tier PKI](https://pkizerotohero.com/handbook/private-ca/)
- [Chapter 16: Taking it back: CRLs, OCSP and stapling](https://pkizerotohero.com/handbook/revocation/)

### Error pages

- [tlsv1 alert unknown ca](https://pkizerotohero.com/errors/openssl-tlsv1-alert-unknown-ca/)
- [SSL certificate problem: unable to get local issuer certificate](https://pkizerotohero.com/errors/curl-unable-to-get-local-issuer-certificate/)

### Tools

- [Certificate decoder](https://pkizerotohero.com/tools/decoder/): Paste a PEM certificate and read every field in plain words.

### Topics

- [TLS and HTTPS](https://pkizerotohero.com/topics/tls/)
- [Private PKI](https://pkizerotohero.com/topics/private-pki/)

## More guides: Harden and secure

- [How to set CAA records for your domain](https://pkizerotohero.com/guides/caa-records/)
- [How to configure TLS securely (versions, ciphers, HSTS)](https://pkizerotohero.com/guides/tls-configuration/)
