# Mastering Mutual TLS (mTLS) with Nginx: A Deep Dive

> One post from jmrp.io, published as its own document. Index: https://jmrp.io/llms-full.txt

Canonical: https://jmrp.io/blog/001-secure-nginx-client-certificates/
Language: en
Alternate: https://jmrp.io/es/blog/001-secure-nginx-client-certificates/index.md
License: https://creativecommons.org/licenses/by/4.0/
Type: TechArticle
Published: 2025-12-16
Updated: 2026-09-02
Instructions re-tested: 2026-08-22 · nginx 1.31.3
Author: José Manuel Requena Plens
Summary: Implement Mutual TLS (mTLS) with Nginx — create a CA, generate client certificates, configure CRL and OCSP revocation, and enable Zero Trust.
Tags: Nginx, Security, Cryptography
Topics: Mutual authentication (Q6944186), Transport Layer Security (Q206494), Nginx (Q306144), Public-key infrastructure (Q658476), OpenSSL (Q1052790), Zero trust architecture (Q104852562)
Build-Date: 2026-09-06

Questions answered:

**What is Mutual TLS (mTLS)?**

Mutual TLS requires the client to present a cryptographic certificate signed by your own Certificate Authority, so both server and client verify each other's identity. If the client doesn't have a valid certificate, Nginx rejects the connection before the application even loads.

**Why can't mTLS be phished?**

Client certificates are never transmitted (only a signature proving possession is sent), are bound to hardware in secure keychains/TPMs, and require private-key access that never leaves the device. Even if an attacker steals your password and intercepts your 2FA code, they still can't access the resource without the certificate file on an authorized device.

**What does the ssl_verify_client directive control in Nginx?**

The ssl_verify_client directive controls how Nginx handles client certificates: on requires a valid certificate and rejects connections without one, optional accepts a valid cert but proceeds without it, optional_no_ca accepts any cert with no CA validation (development only), and off requires no client certificate. Never use optional_no_ca in production, as it defeats the purpose of mTLS.

**How do I revoke a lost or compromised client certificate?**

Use openssl ca -revoke to revoke the certificate, then regenerate the CRL with openssl ca -gencrl and point Nginx at it with ssl_crl. You must regenerate the CRL and reload Nginx every time you revoke a certificate.

**What is the difference between CRL and OCSP revocation?**

CRL has the server download the full list of revoked certificates (simple, file-on-disk, but delayed and higher bandwidth), while OCSP queries a responder for a single certificate (near real-time and lower bandwidth, but requires running an OCSP responder service). CRL is recommended for small deployments; OCSP is more efficient for larger ones.

**What key sizes should I use for the CA and client certificates?**

Use 4096-bit RSA (or P-384 ECDSA) for the CA, with a minimum of 2048-bit keys for clients. The CA should be valid for about 10 years, while client certificates should have a short validity of one year maximum.


Steps (Secure Nginx with mutual TLS (mTLS) client certificates):
1. Set up the PKI directory
2. Create the certificate authority
3. Generate client certificates
4. Export a PKCS12 bundle
5. Configure Nginx for mTLS
6. Add certificate revocation (CRL)
7. Install certificates on client devices

---

**[Mutual TLS (mTLS)](https://www.cloudflare.com/learning/access-management/what-is-mutual-tls/)** represents the gold standard for securing private web services. Unlike passwords, API keys, or even 2FA, mTLS requires the client (your browser/device) to present a cryptographic certificate signed by your own Certificate Authority (CA)—a secret that _cannot_ be phished, guessed, or brute-forced.

If the client doesn't have the certificate, Nginx rejects the connection **before** the application even loads. This is perfect for securing private administration panels, NAS interfaces, or internal tools.

In this comprehensive guide, I'll walk you through implementing mTLS on Nginx, covering everything from creating a certificate authority to advanced topics like **OCSP vs CRL revocation**, **Zero Trust architecture**, and **performance tuning**. This guide incorporates best practices from [Smallstep](https://smallstep.com/hello-mtls), [SSL.com](https://www.ssl.com/article/authenticating-users-and-iot-devices-with-mutual-tls/), and [official Nginx documentation](https://nginx.org/en/docs/http/ngx_http_ssl_module.html).

## TL;DR — mTLS on Nginx in a nutshell

- **mTLS is unphishable** because the client must present a certificate signed by your own CA; the private key never leaves the device and is never transmitted, so Nginx rejects connections without a valid cert **before** the app loads.
- **Build your own PKI**: create a **4096-bit RSA** CA valid for 10 years, then sign per-device client certs (2048-bit minimum, 1-year validity) and distribute them as password-protected **PKCS12 (.p12)** bundles.
- **Enable it in Nginx** with `ssl_client_certificate` (your CA), `ssl_verify_client on`, and `ssl_verify_depth` — or `optional` plus a `map` to enforce it only on critical routes (see my deployment below); never use `optional_no_ca` in production. Restrict to **TLS 1.2/1.3** with strong ECDHE ciphers.
- **Add fine-grained control** with a `map` on `$ssl_client_s_dn` for CN allowlists, and use `$ssl_client_verify` to let authenticated clients **bypass rate limiting**.
- **Revoke compromised certs** via **CRL** (`openssl ca -revoke` + `-gencrl`, then `ssl_crl`) for small setups or **OCSP** for larger ones — regenerate the CRL and reload Nginx every time. Automate creation and renewal with scripts.

---

## Why does Zero Trust rely on mTLS instead of passwords?

In the modern security landscape, the perimeter-based model ("trust everything inside the firewall") is obsolete. **[Zero Trust](https://www.cloudflare.com/learning/security/glossary/what-is-zero-trust/)** assumes that attackers may already be inside your network and requires verification for every access request.

mTLS is a cornerstone of Zero Trust architecture because it provides **mutual authentication**: both the server and client verify each other's identity using cryptographic certificates.

**mTLS vs Other Authentication Methods**

| Method | Phishable? | Credential Storage | Automation | Zero Trust |
| --- | --- | --- | --- | --- |
| **Username/Password** | Yes [bad] | Server database | Easy | No [bad] |
| **API Keys** | Yes (if exposed) [bad] | Server + client | Easy | No [bad] |
| **Password + 2FA** | Partially [caution] | Server + authenticator | Hard | Partial [caution] |
| **mTLS** | No [good] | Hardware/keychain | Medium | Yes [good] |

### Why mTLS cannot be phished

Unlike passwords, client certificates:

1. **Are never transmitted** — only a signature proving possession is sent
2. **Are bound to hardware** — stored in secure keychains/TPMs
3. **Require private key access** — the private key never leaves the device
4. **Are validated cryptographically** — forging is computationally infeasible

**Key point**

**Key Insight:** With mTLS, even if an attacker steals your password AND intercepts your 2FA code, they still cannot access the protected resource without the certificate file installed on an authorized device.

---

## How I run this on my own infrastructure

None of what follows is theory: this blog is served from the same box where mTLS has been protecting my sensitive services since January 2025. My private CA ("JMRP DNS CA", issued with a 10-year lifetime) signs the client certificates that grant access to four services: the Grafana panel, my photo library (Immich), the NAS, and Nginx's own admin interface.

One detail no tutorial told me, and that I only learned by deploying it: in production I do **not** use a bare `ssl_verify_client on`. I run `optional` combined with a `map` and per-location enforcement:

```nginx
map $ssl_client_verify $client_verified {
    "SUCCESS" 1;
    default   0;
}

# On critical routes (e.g. Immich's upload API):
location = /api/assets {
    if ($client_verified = 0) {
        return 403 "Invalid certificate";
    }
    # ...
}
```

Why? With `on` at the server level, any client without a certificate gets a cryptic TLS error before ever reaching Nginx — impossible for a non-technical user to debug, and with no way to fall back to another authentication layer. With `optional` + `map`, the handshake always completes and I decide, route by route, what requires a certificate (write APIs return an explicit 403) and what can fall through to a second authentication layer. That second layer exists: requests from my LAN pass an independent network allowlist, so the certificate is never the only control — defense in depth, not a single master key.

As for issuance: I keep a per-device OpenSSL workflow (a CSR, a config, and a metadata sheet per client), with `ssl_verify_depth 2` on the server. Three certificates are active today, each tied to one specific device and person — when a device is retired, its certificate is retired with it.

An honest confession: **I publish no CRL and no OCSP**. With three certificates on devices I physically control, revocation at this scale means reissuing from the CA — a reasonable trade-off here that would be unacceptable with dozens of clients. If your fleet grows, `ssl_crl` stops being optional.

## What is the difference between one-way TLS and mutual TLS?

Understanding the difference is fundamental:

**One-Way TLS vs Mutual TLS**

| Aspect | One-Way TLS (Standard HTTPS) | Mutual TLS (mTLS) |
| --- | --- | --- |
| **Server authenticates to** | Client (browser) | Client (browser) |
| **Client authenticates to** | Not verified [bad] | Server verifies client cert [good] |
| **Certificate requirement** | Server only | Both server and client |
| **Use case** | Public websites | APIs, admin panels, IoT, B2B |

### The standard HTTPS model

In regular HTTPS, only the server presents a certificate.

The client (browser) verifies it, but the server has no way to verify _who_ the client is—it just knows the connection is encrypted.

### The mTLS enhancement

mTLS adds a second step: after verifying the server, the **server requests a certificate from the client**.

Only clients presenting a valid certificate signed by a trusted CA are allowed to continue.

---

## What happens during an mTLS handshake?

Understanding the handshake helps with debugging.

Here's the complete flow:

**Nginx mTLS Handshake Flow**

```mermaid
sequenceDiagram
    participant C as Client
    participant N as Nginx Server
    
    Note over C,N: TCP Connection Established
    
    C->>N: ClientHello (TLS version, cipher suites)
    N->>C: ServerHello + Server Certificate
    N->>C: CertificateRequest
    Note right of N: "Send me YOUR certificate"
    
    C->>N: Client Certificate
    C->>N: CertificateVerify (signature proof)
    C->>N: Finished
    
    Note over N: Verification Pipeline
    N->>N: 1. Validate CA signature chain
    N->>N: 2. Check against CRL/OCSP
    N->>N: 3. Verify CN/SAN if required
    
    alt All Checks Pass
        N->>C: Finished + 200 OK
        Note over C,N: Encrypted Session Begins
    else Any Check Fails
        N->>C: 400 Bad Request / 403 Forbidden
        Note over C,N: Connection Terminated
    end
```

### Key steps explained

1. **ClientHello**: Client initiates TLS, proposing cipher suites
2. **ServerHello**: Server responds with its certificate
3. **CertificateRequest**: Server asks client to prove identity
4. **Client Certificate**: Client sends its certificate
5. **CertificateVerify**: Client proves it owns the private key (without revealing it)
6. **Verification**: Server validates the entire chain

---

## Prerequisites and directory setup

Before starting, ensure you have:

- A Linux server (Debian/Ubuntu/CentOS/RHEL) with Nginx installed
- [OpenSSL](https://docs.openssl.org/) (minimum version 1.1.1, check with `openssl version`, recommended 3.5.0+)
- Root or sudo access

### Create your PKI directory

A well-organized directory structure is essential for managing certificates:

```bash
# Create the main directory structure
sudo mkdir -p /etc/nginx/pki/{ca,certs,crl,private,newcerts}
cd /etc/nginx/pki

# Secure the private directory
sudo chmod 700 private

# Initialize OpenSSL CA database files
sudo touch index.txt
echo 1000 | sudo tee serial > /dev/null
echo 1000 | sudo tee crlnumber > /dev/null
```

**Info**

**Directory Structure Explained:**

- `ca/` — Certificate Authority certificates
- `certs/` — Issued client and server certificates
- `private/` — Private keys (chmod 700!)
- `crl/` — Certificate Revocation Lists
- `newcerts/` — OpenSSL's archive of issued certs

---

## Creating your certificate authority

A CA is the root of trust in your PKI. The certificates it issues follow the [X.509 v3](https://datatracker.ietf.org/doc/html/rfc5280) (RFC 5280) profile that underpins the public web PKI.

All client certificates must be signed by this CA for Nginx to accept them.

### CA design decisions

Before generating, decide on these parameters:

**CA Configuration Choices**

| Parameter | Recommendation | Rationale |
| --- | --- | --- |
| **Key Size** | 4096-bit RSA or P-384 ECDSA | Stronger security margin against classical attacks |
| **Validity** | 10 years for CA | Longer than any client cert |
| **Hash Algorithm** | SHA-256 minimum | SHA-1 is deprecated |
| **Extensions** | `CA:TRUE`, `keyUsage:keyCertSign,cRLSign` | Required for signing certs and CRLs |

### Create the OpenSSL configuration

Create a comprehensive OpenSSL configuration file:

**File: `/etc/nginx/pki/openssl.cnf`**

```ini
# =============================================================
# OpenSSL Configuration for mTLS Certificate Authority
# =============================================================

[ ca ]
default_ca = CA_default

[ CA_default ]
# Directory structure
dir               = /etc/nginx/pki
database          = $dir/index.txt
new_certs_dir     = $dir/newcerts
certificate       = $dir/ca/ca.crt
serial            = $dir/serial
private_key       = $dir/private/ca.key
crlnumber         = $dir/crlnumber
crl               = $dir/crl/ca.crl

# Certificate defaults
default_days      = 365          # Client certs valid 1 year
default_crl_days  = 30           # CRL valid 30 days
default_md        = sha256       # Use SHA-256
preserve          = no
policy            = policy_loose
copy_extensions   = copy         # Copy SANs from CSR
# SECURITY WARNING: copy_extensions=copy copies ALL extensions from the CSR.
# This is safe only when you trust the CSR generator. A malicious CSR could
# include basicConstraints: CA:TRUE to create a subordinate CA.
# Use copy_extensions=none or explicitly filter extensions for untrusted CSRs.

[ policy_loose ]
countryName             = optional
stateOrProvinceName     = optional
localityName            = optional
organizationName        = optional
organizationalUnitName  = optional
commonName              = supplied
emailAddress            = optional

# =============================================================
# CA Certificate Request Settings
# =============================================================
[ req ]
default_bits        = 4096
distinguished_name  = req_distinguished_name
string_mask         = utf8only
default_md          = sha256
x509_extensions     = v3_ca
prompt              = no

[ req_distinguished_name ]
C                   = ES
ST                  = Valencia
L                   = Valencia
O                   = JMRP-IO-LAB
OU                  = Infrastructure Security
CN                  = JMRP-IO-LAB Root CA

# =============================================================
# Certificate Extensions
# =============================================================

# For the CA certificate itself
[ v3_ca ]
subjectKeyIdentifier    = hash
authorityKeyIdentifier  = keyid:always,issuer
basicConstraints        = critical, CA:TRUE
keyUsage                = critical, digitalSignature, cRLSign, keyCertSign

# For client certificates
[ v3_client ]
subjectKeyIdentifier    = hash
authorityKeyIdentifier  = keyid,issuer
basicConstraints        = critical, CA:FALSE
keyUsage                = critical, nonRepudiation, digitalSignature, keyEncipherment
extendedKeyUsage        = clientAuth
```

### Configuration breakdown

Let's dissect the `openssl.cnf` file to understand what each section controls.

#### 1. CA Global Settings (`[ ca ]` & `[ CA_default ]`)

This section tells OpenSSL where to find your files and how to sign certificates.

**CA Default Settings**

| Directive | Value/Type | Description |
| --- | --- | --- |
| `dir` | Path (String) | Base directory for your PKI (e.g., `/etc/nginx/pki`). |
| `database` | File Path | Text file (`index.txt`) tracking all issued and revoked certificates. |
| `new_certs_dir` | Directory Path | Where copies of every issued certificate are archived by serial number. |
| `certificate` | File Path | The CA's own public certificate used to sign others. |
| `private_key` | File Path | The CA's private key. **Must be protected.** |
| `default_days` | Integer (Days) | Default validity period for issued certificates (e.g., 365). |
| `policy` | Section Name | Which policy section to enforce for matching DN fields (e.g., `policy_loose`). |
| `copy_extensions` | `copy` \| `none` | **Crucial:** Copies SANs (Subject Alternative Names) from the CSR to the final certificate. |

#### 2. Validation Policies (`[ policy_loose ]`)

Controls which fields in the Certificate Signing Request (CSR) must match the CA's own fields.

**Policy Rules**

| Field | Rule | Meaning |
| --- | --- | --- |
| `countryName` | `optional` | The client doesn't need to specify a country. |
| `commonName` | `supplied` | **Required.** The client must provide a Common Name (used for identification). |
| `organizationName` | `optional` | Can be left empty. |

#### 3. Request Settings (`[ req ]`)

Defaults used when you run `openssl req` to generate keys or CSRs.

**Request Defaults**

| Directive | Value | Description |
| --- | --- | --- |
| `default_bits` | Integer (e.g., 4096) | Default key size if not specified. |
| `distinguished_name` | Section Name | Points to the section defining default DN values (`[ req_distinguished_name ]`). |
| `x509_extensions` | Section Name | Extensions to add when creating a **self-signed** root certificate (e.g., `v3_ca`). |

#### 4. Certificate Extensions (`[ v3_* ]`)

These sections define the "capabilities" of the certificates you issue. This is the security core.

**Extension Profiles**

| Profile/Directive | Value | Effect |
| --- | --- | --- |
| **[ v3_ca ] - For the Root CA** |  |  |
| `basicConstraints` | `critical, CA:TRUE` | **Identity:** Marks this cert as a Certificate Authority that can sign others. |
| `keyUsage` | `keyCertSign, cRLSign` | **Permissions:** Allowed to sign certificates and CRLs. |
| **[ v3_client ] - For User Devices** |  |  |
| `basicConstraints` | `critical, CA:FALSE` | **Restriction:** This certificate **cannot** be used to sign other certificates. |
| `extendedKeyUsage` | `clientAuth` | **Purpose:** Valid **only** for authenticating a client to a server (mTLS). |

### Generate the CA key and certificate

```bash
cd /etc/nginx/pki

# Generate CA private key (4096-bit RSA)
sudo openssl genrsa -out private/ca.key 4096

# Secure the private key
sudo chmod 400 private/ca.key

# Generate the self-signed CA certificate (valid 10 years)
sudo openssl req -new -x509 -days 3650 \
    -key private/ca.key \
    -out ca/ca.crt \
    -config openssl.cnf \
    -extensions v3_ca
```

### Verify your CA certificate

```bash
openssl x509 -in ca/ca.crt -text -noout | head -30
```

**Output — CA Certificate Details**

```text
Certificate:
    Data:
        Version: 3 (0x2)
        Serial Number:
            1f:23:94:bf:00:2d:11:f7:c5:14:ac:c6:24:a9:d1:ff:b3:4e:cc:3a
        Signature Algorithm: sha256WithRSAEncryption
        Issuer: C=ES, ST=Valencia, L=Valencia, O=JMRP-IO-LAB, OU=Infrastructure Security, CN=JMRP-IO-LAB Root CA
        Validity
            Not Before: Jan 13 18:21:21 2026 GMT
            Not After : Jan 11 18:21:21 2036 GMT
        Subject: C=ES, ST=Valencia, L=Valencia, O=JMRP-IO-LAB, OU=Infrastructure Security, CN=JMRP-IO-LAB Root CA
        Subject Public Key Info:
            Public Key Algorithm: rsaEncryption
                Public-Key: (4096 bit)
            X509v3 extensions:
                X509v3 Subject Key Identifier: 
                    ...
                X509v3 Basic Constraints: critical
                    CA:TRUE
                X509v3 Key Usage: critical
                    Digital Signature, Certificate Sign, CRL Sign
```

---

## Generating client certificates

Each client (device, user, or service) needs its own certificate signed by your CA.

### SAN vs CN: which to use?

**Subject Alternative Name (SAN) vs Common Name (CN)**

| Aspect | Common Name (CN) | Subject Alternative Name (SAN) |
| --- | --- | --- |
| **Standard** | Legacy (deprecated for server certs) | Modern, RFC 6125 compliant |
| **Multiple identities** | No [bad] | Yes (DNS, email, URI) [good] |
| **Best for** | Simple client identification | Services, multi-identity clients |

For simple user identification (like we're doing), CN is sufficient. For service-to-service authentication, use SANs.

### Generate a client certificate

```bash
cd /etc/nginx/pki

# Set device/user name
CLIENT_NAME="iphone-jmrp"

# Generate client private key
sudo openssl genrsa -out private/${CLIENT_NAME}.key 2048

# Create Certificate Signing Request (CSR)
sudo openssl req -new \
    -key private/${CLIENT_NAME}.key \
    -out certs/${CLIENT_NAME}.csr \
    -subj "/CN=${CLIENT_NAME}"

# Sign with your CA
sudo openssl ca -config openssl.cnf \
    -extensions v3_client \
    -batch \
    -in certs/${CLIENT_NAME}.csr \
    -out certs/${CLIENT_NAME}.crt
```

**Certificate Signing Output**

```text
Using configuration from openssl.cnf
Check that the request matches the signature
Signature ok
The Subject's Distinguished Name is as follows
commonName            :ASN.1 12:'iphone-jmrp'
Certificate is to be certified until Jan 13 18:21:36 2027 GMT (365 days)

Write out database with 1 new entries
Database updated
```

### PKCS12 or PEM: which export format do you need?

**Certificate Format Comparison**

| Format | Contains | Best For | Password Protected |
| --- | --- | --- | --- |
| **PEM (.crt, .key)** | Separate text files | Linux servers, scripting | Optional |
| **PKCS12 (.p12, .pfx)** | All-in-one binary bundle | Browsers, mobile, Windows | Required |

### Create PKCS12 bundle for clients

```bash
# Bundle certificate, key, and CA into .p12 file
sudo openssl pkcs12 -export \
    -out certs/${CLIENT_NAME}.p12 \
    -inkey private/${CLIENT_NAME}.key \
    -in certs/${CLIENT_NAME}.crt \
    -certfile ca/ca.crt \
    -name "${CLIENT_NAME}"

# You will be prompted for an export password
# This protects the .p12 file during transfer
```

**Warning**

**Security:** Choose a strong export password. This password protects the certificate during transfer to client devices. Transmit it separately from the .p12 file (e.g., via encrypted message or in person).

---

## Configuring Nginx for mTLS

Now configure Nginx to require and validate client certificates.

### Which ssl_verify_client option should you use?

The `ssl_verify_client` directive controls how Nginx handles client certificates:

**ssl_verify_client Options**

| Value | Behavior | Use Case |
| --- | --- | --- |
| `on` | Require valid cert; reject without | Strict access control (recommended) |
| `optional` | Accept if valid; proceed without | Hybrid auth, optional verification |
| `optional_no_ca` | Accept any cert, no CA validation | Development/testing only |
| `off` | No client certificate required | Standard HTTPS |

**Warning**

**Never use `optional_no_ca` in production!** It accepts _any_ certificate without validating the CA chain, defeating the purpose of mTLS.

### Complete Nginx configuration

**File: `/etc/nginx/sites-available/secure.example.com.conf`**

```nginx
server {
    listen 443 ssl;
    listen [::]:443 ssl;
    http2 on;
    server_name secure.example.com;
    
    # =================================================
    # Server TLS Configuration
    # =================================================
    ssl_certificate     /etc/ssl/certs/secure.example.com.crt;
    ssl_certificate_key /etc/ssl/private/secure.example.com.key;
    
    # =================================================
    # Client Certificate (mTLS) Configuration
    # =================================================
    
    # Path to your Certificate Authority
    ssl_client_certificate /etc/nginx/pki/ca/ca.crt;
    
    # Require valid client certificate
    ssl_verify_client on;
    
    # Verify up to 2 levels in the certificate chain
    ssl_verify_depth 2;
    
    # Optional: Certificate Revocation List
    # ssl_crl /etc/nginx/pki/crl/ca.crl;
    
    # =================================================
    # TLS Protocol Configuration
    # =================================================
    ssl_protocols TLSv1.2 TLSv1.3;
    ssl_ciphers ECDHE-ECDSA-AES128-GCM-SHA256:ECDHE-RSA-AES128-GCM-SHA256:ECDHE-ECDSA-AES256-GCM-SHA384:ECDHE-RSA-AES256-GCM-SHA384:ECDHE-ECDSA-CHACHA20-POLY1305:ECDHE-RSA-CHACHA20-POLY1305;
    ssl_prefer_server_ciphers on;
    
    # Performance: SSL Session Caching
    ssl_session_cache shared:SSL:10m;
    ssl_session_timeout 1d;
    ssl_session_tickets off;
    
    # =================================================
    # Logging (Include Client DN for Debugging)
    # =================================================
    access_log /var/log/nginx/secure.access.log combined;
    error_log /var/log/nginx/secure.error.log warn;
    
    # =================================================
    # Application
    # =================================================
    location / {
        # First, clear any client-provided values to prevent spoofing
        proxy_set_header X-Client-DN "";
        proxy_set_header X-Client-Verify "";
        proxy_set_header X-Client-Fingerprint "";
        
        # Then set authenticated values from Nginx's SSL module
        # Backend should only trust these when requests come directly from Nginx
        proxy_set_header X-Client-DN $ssl_client_s_dn;
        proxy_set_header X-Client-Verify $ssl_client_verify;
        proxy_set_header X-Client-Fingerprint $ssl_client_fingerprint;
        
        proxy_pass http://127.0.0.1:8080;
    }
}
```

**Warning**

**Trust Boundary Warning:** The `X-Client-DN`, `X-Client-Verify`, and `X-Client-Fingerprint` headers can be spoofed by malicious clients if upstream proxies don't clear them. Ensure:

- Any load balancer or reverse proxy in front of Nginx strips these headers before forwarding
- Backend applications only trust these headers when requests come directly from Nginx (validate source IP or use mTLS between Nginx and backend)
- Consider using an internal-only network interface for the Nginx-to-backend connection

### Test and reload

```bash
# Test configuration syntax
sudo nginx -t

# If successful, reload
sudo systemctl reload nginx
```

Now, let's verify the connections using `curl`.

**1. Access Denied (No Certificate):**

**Output — curl -v https://secure.example.com**

```text
> GET / HTTP/1.1
> Host: secure.example.com
> User-Agent: curl/8.14.1
> Accept: */*
> 
< HTTP/1.1 400 Bad Request
< Server: nginx
< Content-Type: text/html
< Content-Length: 230
< Connection: close
< 
<html>
<head><title>400 No required SSL certificate was sent</title></head>
<body>
<center><h1>400 Bad Request</h1></center>
<center>No required SSL certificate was sent</center>
<hr><center>nginx</center>
</body>
</html>
```

**2. Access Granted (With Valid Certificate):**

**Output — curl -v --cert client.crt --key client.key https://secure.example.com**

```text
* TLSv1.3 (IN), TLS handshake, Request CERT (13):
{ [210 bytes data]
* TLSv1.3 (OUT), TLS handshake, Certificate (11):
} [812 bytes data]
* TLSv1.3 (OUT), TLS handshake, CERT verify (15):
} [264 bytes data]
* TLSv1.3 (IN), TLS handshake, Finished (20):
{ [52 bytes data]
* SSL connection using TLSv1.3 / TLS_AES_256_GCM_SHA384
* Server certificate:
*  subject: CN=secure.example.com
*  issuer: CN=secure.example.com
> GET / HTTP/1.1
> Host: secure.example.com
> User-Agent: curl/8.14.1
> 
< HTTP/1.1 200 OK
< Server: nginx
< Content-Type: text/plain
mTLS Authentication Successful! Client DN: CN=iphone-jmrp
```

---

## TLS protocol and cipher configuration

Proper TLS configuration is critical for security.

Here's what each setting does:

### Protocol version selection

Only TLS 1.2 and TLS 1.3 belong in a modern configuration. [TLS 1.3](https://datatracker.ietf.org/doc/html/rfc8446) (RFC 8446) is the current standard, with a faster handshake and AEAD-only cipher suites:

**TLS Protocol Versions**

| Version | Status | Security |
| --- | --- | --- |
| TLS 1.0 | Deprecated [bad] | Vulnerable (BEAST, POODLE) |
| TLS 1.1 | Deprecated [bad] | Weak ciphers, no AEAD |
| TLS 1.2 | Supported [good] | Secure with proper ciphers |
| TLS 1.3 | Recommended [good] | Best security, faster handshake |

### Recommended configuration

**TLS Security Settings**

```nginx
# Only allow TLS 1.2 and 1.3
ssl_protocols TLSv1.2 TLSv1.3;

# Strong cipher suites (ECDHE for forward secrecy, GCM for AEAD)
ssl_ciphers ECDHE-ECDSA-AES128-GCM-SHA256:ECDHE-RSA-AES128-GCM-SHA256:ECDHE-ECDSA-AES256-GCM-SHA384:ECDHE-RSA-AES256-GCM-SHA384:ECDHE-ECDSA-CHACHA20-POLY1305:ECDHE-RSA-CHACHA20-POLY1305;

# Server chooses the cipher (prevents downgrade attacks)
ssl_prefer_server_ciphers on;

# Session resumption for performance
ssl_session_cache shared:SSL:10m;
ssl_session_timeout 1d;

# Disable session tickets (more secure, less memory)
ssl_session_tickets off;

# Diffie-Hellman parameters for non-ECDHE (if needed)
# Generate with: openssl dhparam -out /etc/nginx/dhparam.pem 4096
ssl_dhparam /etc/nginx/dhparam.pem;
```

**Tip**

**Performance Tip:** SSL session caching significantly reduces handshake overhead. A 10MB cache can store ~40,000 sessions. This is especially important for mTLS where handshakes are more expensive.

---

## How do you limit what each client certificate can reach?

By default, _any_ client with a valid certificate signed by your CA can access the site.

You can add granular controls using Nginx variables.

### Useful mTLS variables

Nginx exposes these variables for client certificate information:

**Nginx mTLS Variables**

| Variable | Content | Example |
| --- | --- | --- |
| `$ssl_client_verify` | Verification result | `SUCCESS`, `FAILED:reason` |
| `$ssl_client_s_dn` | Subject DN | `CN=iphone-jmrp` |
| `$ssl_client_i_dn` | Issuer DN | `CN=JMRP-IO-LAB Root CA` |
| `$ssl_client_fingerprint` | SHA1 fingerprint | `AB:CD:EF:12:34:...` |
| `$ssl_client_serial` | Certificate serial | `1000` |

### CN-based authorization with map

Use the `map` directive to create an allowlist:

**File: `/etc/nginx/conf.d/mtls-access.conf`**

```nginx
# Place this OUTSIDE the server block (in nginx.conf or included file)

# Map client DN to access permission
map $ssl_client_s_dn $mtls_access_allowed {
    default 0;
    
    # Allowed certificates by CN
    "CN=iphone-jmrp"      1;
    "CN=macbook-jmrp"     1;
    "CN=ipad-home"        1;
    
    # Pattern matching with regex
    ~"CN=admin-.*"        1;    # All admin-* certificates
}
```

Then in your server block:

**Server Block Access Control**

```nginx
server {
    # ... SSL configuration ...
    
    location / {
        # Verify certificate is valid (handled by ssl_verify_client on)
        # Then check our custom authorization
        if ($mtls_access_allowed = 0) {
            return 403 "Valid certificate, but not authorized for this resource.";
        }
        
        proxy_pass http://backend;
    }
}
```

---

## Advanced use case: rate limit bypass

One often overlooked benefit of mTLS is the ability to trust authenticated clients at the network level.

For example, you might want to apply strict **DDoS protection** and **Rate Limiting** to the public internet, but allow your own devices (authenticated via mTLS) to have unlimited access.

You can achieve this in Nginx using a `map` directive to whitelist verified clients.

### Configuration example

**File: `/etc/nginx/nginx.conf`**

```nginx
http {
    # ...
    
    # 1. Map the verification status to a rate-limit key
    map $ssl_client_verify $limit_req_whitelist {
        "SUCCESS" "";                  # If verified, key is empty (no limit)
        default   $binary_remote_addr; # Otherwise, limit by IP
    }

    # 2. Define the zone using the map variable
    # If $limit_req_whitelist is empty, the request is not counted!
    limit_req_zone $limit_req_whitelist zone=req_limit_per_ip:10m rate=5r/s;

    server {
        # IMPORTANT: To allow unauthenticated clients to reach this logic,
        # you must set 'ssl_verify_client optional' instead of 'on'.
        # If set to 'on', Nginx rejects the handshake before the rate limiter runs.
        ssl_verify_client optional;

        # 3. Apply the limit
        limit_req zone=req_limit_per_ip burst=10 nodelay;
        
        # ...
    }
}
```

**Key point**

**How it works:** Nginx `limit_req_zone` ignores any request where the key variable is empty. By mapping `SUCCESS` to an empty string, mTLS-authenticated clients completely bypass the rate limiter, while everyone else is tracked by their IP address.

---

## Certificate revocation: CRL vs OCSP

What happens when a device is lost or compromised?

You need to **revoke** its certificate. There are two methods:

**CRL vs OCSP Comparison**

| Aspect | CRL (Certificate Revocation List) | OCSP (Online Certificate Status) |
| --- | --- | --- |
| **How it works** | Server downloads full list of revoked certs | Server queries responder for single cert |
| **Timeliness** | Delayed (depends on CRL refresh interval) | Near real-time |
| **Bandwidth** | Higher (downloads entire list) | Lower (single query) |
| **Nginx directive** | `ssl_crl` | `ssl_stapling` (for server cert) |
| **Complexity** | Simple (file on disk) | Requires OCSP responder service |

### Method 1: CRL (recommended for small deployments)

#### Step 1: revoke a certificate

```bash
cd /etc/nginx/pki

# Revoke the certificate
sudo openssl ca -config openssl.cnf \
    -revoke certs/lost-device.crt
```

**Revocation Output**

```text
Using configuration from openssl.cnf
Revoking Certificate 1000.
Database updated
```

#### Step 2: generate the CRL file

```bash
# Generate the CRL
sudo openssl ca -config openssl.cnf \
    -gencrl -out crl/ca.crl

# Verify the CRL
openssl crl -in crl/ca.crl -text -noout
```

#### Step 3: configure Nginx

**Add to server block**

```nginx
# Enable CRL checking
ssl_crl /etc/nginx/pki/crl/ca.crl;
```

```bash
sudo nginx -t && sudo systemctl reload nginx
```

**Warning**

**Important:** You must regenerate the CRL and reload Nginx **every time** you revoke a certificate. Consider automating this with a cron job.

### Verify revocation

Accessing the site with the revoked certificate should now fail:

**Output — curl -v --cert revoked.crt ...**

```text
< HTTP/1.1 400 Bad Request
< Server: nginx
< Content-Type: text/html
< Content-Length: 208
< Connection: close
< 
<html>
<head><title>400 The SSL certificate error</title></head>
<body>
<center><h1>400 Bad Request</h1></center>
<center>The SSL certificate error</center>
<hr><center>nginx</center>
</body>
</html>
```

### Method 2: OCSP (for larger deployments)

For enterprises with many certificates, OCSP is more efficient.

This requires running an OCSP responder (beyond the scope of this guide, but tools like [step-ca](https://smallstep.com/docs/step-ca/) provide this).

---

## Certificate lifecycle management

A complete lifecycle includes creation, distribution, monitoring, renewal, and revocation.

### The certificate lifecycle

**Certificate Lifecycle Flow**

```mermaid
flowchart LR
    A[Generate Key & CSR] --> B[Sign with CA]
    B --> C[Export to PKCS12]
    C --> D[Secure Distribution]
    D --> E[Install on Device]
    E --> F[Monitor Expiration]
    F --> G{Expired?}
    G -->|No| F
    G -->|Yes| H[Renew]
    H --> B
    E --> I{Compromised?}
    I -->|Yes| J[Revoke & Update CRL]
    J --> F
```

## Automation scripts

Managing certificates manually is error-prone.

Here are two essential scripts to automate the lifecycle.

### 1. Client certificate generator

This script automates the entire creation process

: generating the key, CSR, signing it, and exporting the PKCS#12 bundle.

**File: `/usr/local/bin/mtls-add-client.sh`**

```bash
#!/bin/bash
set -e

# Configuration
PKI_DIR="/etc/nginx/pki"
DAYS_VALID=365
CLIENT_NAME=$1
EXPORT_PASS=$2

if [ -z "$CLIENT_NAME" ]; then
    echo "Usage: $0 <client-name> [export-password]"
    exit 1
fi

# Validate CLIENT_NAME: only allow alphanumerics, dots, underscores, hyphens
# Reject path traversal sequences, leading dots, slashes, and other unsafe chars
if ! echo "$CLIENT_NAME" | grep -qE '^[A-Za-z0-9._-]+$'; then
    echo "❌ Error: CLIENT_NAME contains invalid characters."
    echo "   Only alphanumerics, dots, underscores, and hyphens are allowed."
    exit 1
fi

# Additional check to prevent path traversal patterns
if echo "$CLIENT_NAME" | grep -qE '(\.\.|^\.)|/'; then
    echo "❌ Error: CLIENT_NAME contains path traversal sequences or slashes."
    exit 1
fi

if [ -z "$EXPORT_PASS" ]; then
    echo "No password provided. Generating a random one..."
    EXPORT_PASS=$(openssl rand -base64 12)
    echo "Generated Password: $EXPORT_PASS"
fi

cd $PKI_DIR

echo "1. Generating private key for $CLIENT_NAME..."
openssl genrsa -out private/${CLIENT_NAME}.key 2048

echo "2. Creating CSR..."
openssl req -new -key private/${CLIENT_NAME}.key \
    -out certs/${CLIENT_NAME}.csr \
    -subj "/CN=${CLIENT_NAME}"

echo "3. Signing certificate..."
openssl ca -config openssl.cnf \
    -extensions v3_client \
    -days $DAYS_VALID \
    -batch \
    -in certs/${CLIENT_NAME}.csr \
    -out certs/${CLIENT_NAME}.crt

echo "4. Exporting to PKCS12 (.p12)..."
# Use stdin for password to avoid exposure in process list
echo "$EXPORT_PASS" | openssl pkcs12 -export \
    -out certs/${CLIENT_NAME}.p12 \
    -inkey private/${CLIENT_NAME}.key \
    -in certs/${CLIENT_NAME}.crt \
    -certfile ca/ca.crt \
    -name "${CLIENT_NAME}" \
    -passout stdin

echo "----------------------------------------"
echo "✅ Certificate created successfully!"
echo "Files:"
echo " - Key: $PKI_DIR/private/${CLIENT_NAME}.key"
echo " - Cert: $PKI_DIR/certs/${CLIENT_NAME}.crt"
echo " - Bundle: $PKI_DIR/certs/${CLIENT_NAME}.p12"
echo "----------------------------------------"
echo "⚠️  Export Password: $EXPORT_PASS"
echo "Transfer the .p12 file securely to the client device."
```

```bash
# Usage example: Generate certificate for a new device
sudo /usr/local/bin/mtls-add-client.sh my-iphone securepassword
```

### 2. Auto-renewal script

This script checks for expiring certificates

, revokes them, re-signs the original CSR, and generates a new `.p12` bundle.

**File: `/usr/local/bin/mtls-renew.sh`**

```bash
#!/bin/bash
# mTLS Certificate Renewal Script
set -e

PKI_DIR="/etc/nginx/pki"
DAYS_BEFORE_EXPIRY=30
RENEW_DAYS=365
RENEWED=false

echo "Checking for expiring certificates in $PKI_DIR/certs..."

while read -r cert; do
    # Extract expiration date
    end_date=$(openssl x509 -enddate -noout -in "$cert" | cut -d= -f2)
    end_epoch=$(date -d "$end_date" +%s)
    now_epoch=$(date +%s)
    days_left=$(( (end_epoch - now_epoch) / 86400 ))
    
    # Extract Common Name robustly (handles "CN=Name", "CN = Name", and trailing DN fields)
    CN=$(openssl x509 -subject -noout -nameopt RFC2253 -in "$cert" | \
         sed -n 's/.*CN=\([^,/]*\).*/\1/p' | sed 's/^[[:space:]]*//;s/[[:space:]]*$//')
    
    if [ "$days_left" -lt "$DAYS_BEFORE_EXPIRY" ]; then
        echo "--------------------------------------------------"
        echo "⚠️  Certificate for \"$CN\" expires in $days_left days."
        echo "   (File: $cert)"
        echo "   Renewing now..."
        
        # 1. Archive old cert
        cp "$cert" "$cert.old.$(date +%F)"
        
        # 2. Re-sign the existing CSR
        # We assume CSR exists in certs/ directory as $CN.csr
        CSR="$PKI_DIR/certs/$CN.csr"
        if [ ! -f "$CSR" ]; then
            echo "   ❌ CSR not found at $CSR. Cannot renew automatically."
            continue
        fi
        
        # Revoke old cert (to clear DB index and maintain CRL)
        openssl ca -config $PKI_DIR/openssl.cnf \
            -revoke "$cert"
        
        # Regenerate CRL immediately after revocation
        openssl ca -config $PKI_DIR/openssl.cnf \
            -gencrl -out $PKI_DIR/crl/ca.crl
        
        # Re-sign
        openssl ca -config $PKI_DIR/openssl.cnf \
            -extensions v3_client \
            -days $RENEW_DAYS \
            -batch \
            -in "$CSR" \
            -out "$cert"
            
        echo "   ✅ Certificate re-signed. New validity: $RENEW_DAYS days."
        echo "   ✅ CRL regenerated."
        
        # 3. Generate new P12
        NEW_PASS=$(openssl rand -base64 12)
        P12="$PKI_DIR/certs/$CN.p12"
        KEY="$PKI_DIR/private/$CN.key"
        
        if [ -f "$KEY" ]; then
            # Use stdin for password to avoid exposure in process list
            echo "$NEW_PASS" | openssl pkcs12 -export \
                -out "$P12" \
                -inkey "$KEY" \
                -in "$cert" \
                -certfile $PKI_DIR/ca/ca.crt \
                -name "$CN" \
                -passout stdin
                
            echo "   📦 New PKCS#12 bundle created: $P12"
            echo "   🔑 New Export Password: $NEW_PASS"
        else
            echo "   ⚠️ Private key not found. Skipped P12 generation."
        fi
        
        # Mark that we renewed at least one certificate
        RENEWED=true
        echo "--------------------------------------------------"
    else
        echo "✅ $CN: $days_left days remaining."
    fi
done < <(find "$PKI_DIR/certs" -name "*.crt" -type f)

# Reload Nginx if any certificates were renewed
if [ "$RENEWED" = "true" ]; then
    echo ""
    echo "========================================"
    echo "🔄 Reloading Nginx to apply updated CRL..."
    echo "========================================"
    
    # Test configuration first
    if nginx -t 2>&1; then
        systemctl reload nginx
        echo "✅ Nginx reloaded successfully."
    else
        echo "❌ Nginx configuration test failed. Skipping reload."
        exit 1
    fi
fi
```

**Warning**

**Platform Note:** This script uses GNU `date -d` which is Linux-specific. On macOS/BSD, you'll need to modify the `end_epoch` calculation using `date -j -f` or use a cross-platform tool like Python's `datetime` module.

Schedule this in crontab to run daily:

**crontab -e**

```text
0 3 * * * /usr/local/bin/mtls-renew.sh >> /var/log/mtls-renew.log 2>&1
```

---

## Installing certificates on clients

Transfer the `.p12` file securely

(AirDrop, USB, encrypted cloud) and install on each device.

**Option 1/5 — iOS (iPhone/iPad)**

1. Transfer the `.p12` file via **AirDrop** or save to **Files** app
2. Tap on the file in Files
3. Go to **Settings** → **Profile Downloaded** (appears at top)
4. Tap **Install** and enter your device passcode
5. Enter the certificate export password
6. The certificate is now installed. Safari will use it automatically when visiting the mTLS site

**Tip**

**Tip:** If Safari doesn't prompt for the certificate, try private browsing first, then regular mode.

**Option 2/5 — macOS**

1. Double-click the `.p12` file
2. **Keychain Access** opens. Enter the export password
3. Choose the **login** keychain (not System)
4. Click **Add**
5. Restart your browser
6. When visiting the site, macOS prompts "Select a Certificate"

**Info**

**Note:** You may need to set the certificate to "Always Trust" in Keychain Access if prompted.

**Option 3/5 — Windows**

1. Double-click the `.p12` file
2. **Certificate Import Wizard** launches
3. Select **Current User** (not Local Machine)
4. Enter the password
5. Select "Automatically select the certificate store"
6. Click **Finish**
7. Restart your browser

Edge and Chrome will now present the certificate when needed.

**Option 4/5 — Android**

1. Save `.p12` to device storage
2. Go to **Settings** → **Security** → **Encryption & credentials**
3. Tap **Install a certificate** → **VPN & app user certificate**
4. Browse to the `.p12` file
5. Enter the password and provide a name
6. The certificate is installed for Chrome and other apps

**Option 5/5 — Linux (curl)**

For command-line/scripting access using PEM files:

```bash
# Using curl with client certificate
curl --cert /path/to/client.crt \
     --key /path/to/client.key \
     --cacert /path/to/ca.crt \
     https://secure.example.com

# Or with PKCS12
curl --cert-type P12 \
     --cert /path/to/client.p12:password \
     https://secure.example.com
```

---

## Troubleshooting guide

### Common errors and solutions

**mTLS Error Diagnosis**

| Error | Cause | Solution |
| --- | --- | --- |
| `400 No required SSL certificate was sent` | Browser didn't send a certificate | Check certificate is installed; restart browser; try incognito mode |
| `400 The SSL certificate error` | Certificate validation failed | Verify `ssl_client_certificate` points to correct CA |
| `403 Forbidden` | Cert valid but not authorized | Check CRL or `map` rules; examine `error_log` |
| Certificate selection loop | OS keeps prompting | Trust the CA in keychain; restart browser |
| `ssl_client_verify: FAILED` | Chain validation failed | Ensure full chain in ssl_client_certificate |

### Debugging commands

#### Verify certificate against CA

```bash
# Verify a client certificate is valid against your CA
openssl verify -CAfile /etc/nginx/pki/ca/ca.crt \
    /etc/nginx/pki/certs/client.crt
```

#### Test mTLS connection with curl

```bash
# Test mTLS connection
curl -v --cert client.crt --key client.key \
    --cacert ca.crt https://secure.example.com

# If using PKCS12
curl -v --cert-type P12 --cert client.p12:password \
    https://secure.example.com
```

#### Check Nginx error logs

```bash
# Watch error log in real-time
tail -f /var/log/nginx/error.log | grep ssl
```

**Output — Example Error Log Entry**

```text
2026/01/12 12:00:00 [info] 1234#5678: *1 client certificate verification failed: certificate revoked, client: 192.168.1.100, server: secure.example.com
```

---

## Security best practices

### Key management

**Warning**

**Critical:** Your CA private key (`ca.key`) is the crown jewel. If compromised, an attacker can issue valid certificates. Protect it with:

- File permissions: `chmod 400 ca.key`
- Separate from web server (air-gapped for high security)
- HSM for enterprise deployments

### Checklist

1. **Use 4096-bit RSA keys** for CA (2048 minimum for clients)
2. **Enable CRL/OCSP** to revoke compromised certificates
3. **Short client cert validity** (1 year max, shorter for high security)
4. **TLS 1.2+** only, with strong ciphers
5. **Session caching** for performance
6. **Monitor expiry** and automate renewal
7. **Separate CAs** for different trust levels if needed
8. **Log client DNs** for audit trails
9. **Regular rotation** of client certificates

---

## Deep Dive

### Official Documentation

- [Nginx ngx_http_ssl_module](https://nginx.org/en/docs/http/ngx_http_ssl_module.html) — All SSL/TLS directives
- [OpenSSL CA Documentation](https://docs.openssl.org/3.5/man1/openssl-ca/) — Certificate authority commands

### Tutorials & Guides

- [Smallstep Hello mTLS](https://smallstep.com/hello-mtls) — Interactive mTLS tutorial
- [SSL.com mTLS Guide](https://www.ssl.com/article/authenticating-users-and-iot-devices-with-mutual-tls/) — IoT and user authentication
- [Cloudflare: What is mTLS?](https://www.cloudflare.com/learning/access-management/what-is-mutual-tls/) — Conceptual overview

### Tools

- [step-ca](https://smallstep.com/docs/step-ca/) — Modern certificate authority
- [cfssl](https://github.com/cloudflare/cfssl) — Cloudflare's PKI toolkit
- [easy-rsa](https://github.com/OpenVPN/easy-rsa) — Simple CA management scripts

