Mastering Mutual TLS (mTLS) with Nginx: A Deep Dive
A comprehensive guide to implementing Mutual TLS (mTLS) with Nginx. Learn how to create your own Certificate Authority, generate client certificates, configure certificate revocation with CRL and OCSP, and implement Zero Trust authentication.

Mutual TLS (mTLS) 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, SSL.com, and official Nginx documentation.
Why mTLS? The Zero Trust Approach
In the modern security landscape, the perimeter-based model (“trust everything inside the firewall”) is obsolete. 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.
| Method | Phishable? | Credential Storage | Automation | Zero Trust |
|---|---|---|---|---|
| Username/Password | Yes | Server database | Easy | No |
| API Keys | Yes (if exposed) | Server + client | Easy | No |
| Password + 2FA | Partially | Server + authenticator | Hard | Partial |
| mTLS | No | Hardware/keychain | Medium | Yes |
Why mTLS cannot be phished
Unlike passwords, client certificates:
- Are never transmitted — only a signature proving possession is sent
- Are bound to hardware — stored in secure keychains/TPMs
- Require private key access — the private key never leaves the device
- Are validated cryptographically — forging is computationally infeasible
One-Way TLS vs Mutual TLS
Understanding the difference is fundamental:
| Aspect | One-Way TLS (Standard HTTPS) | Mutual TLS (mTLS) |
|---|---|---|
| Server authenticates to | Client (browser) | Client (browser) |
| Client authenticates to | Not verified | Server verifies client cert |
| 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.
The mTLS handshake flow
Understanding the handshake helps with debugging.
Here’s the complete flow:
Key steps explained
- ClientHello: Client initiates TLS, proposing cipher suites
- ServerHello: Server responds with its certificate
- CertificateRequest: Server asks client to prove identity
- Client Certificate: Client sends its certificate
- CertificateVerify: Client proves it owns the private key (without revealing it)
- 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 (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:
Creating your certificate authority
A CA is the root of trust in your PKI.
All client certificates must be signed by this CA for Nginx to accept them.
CA design decisions
Before generating, decide on these parameters:
| 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:
# =============================================================
# 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 = clientAuthConfiguration 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.
| 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.
| 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.
| 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.
| 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
Verify your CA certificate
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?
| Aspect | Common Name (CN) | Subject Alternative Name (SAN) |
|---|---|---|
| Standard | Legacy (deprecated for server certs) | Modern, RFC 6125 compliant |
| Multiple identities | No | Yes (DNS, email, URI) |
| 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
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 vs PEM: export formats
| 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
Configuring Nginx for mTLS
Now configure Nginx to require and validate client certificates.
Understanding ssl_verify_client options
The ssl_verify_client directive controls how Nginx handles client certificates:
| 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 |
Complete Nginx configuration
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;
}
}Test and reload
Now, let’s verify the connections using curl.
1. Access Denied (No Certificate):
Show curl output (400 Bad Request)
> 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):
Show curl output (200 OK)
* 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-jmrpTLS protocol and cipher configuration
Proper TLS configuration is critical for security.
Here’s what each setting does:
Protocol version selection
| Version | Status | Security |
|---|---|---|
| TLS 1.0 | Deprecated | Vulnerable (BEAST, POODLE) |
| TLS 1.1 | Deprecated | Weak ciphers, no AEAD |
| TLS 1.2 | Supported | Secure with proper ciphers |
| TLS 1.3 | Recommended | Best security, faster handshake |
Recommended configuration
# 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;Advanced access control
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:
| 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:
# 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 {
# ... 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
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;
# ...
}
}Certificate revocation: CRL vs OCSP
What happens when a device is lost or compromised?
You need to revoke its certificate. There are two methods:
| 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
Using configuration from openssl.cnf Revoking Certificate 1000. Database updated
Step 2: generate the CRL file
Step 3: configure Nginx
# Enable CRL checking
ssl_crl /etc/nginx/pki/crl/ca.crl;Verify revocation
Accessing the site with the revoked certificate should now fail:
Show curl output (Revoked - 400 Bad Request)
< 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 provide this).
Certificate lifecycle management
A complete lifecycle includes creation, distribution, monitoring, renewal, and revocation.
The certificate lifecycle
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.
/usr/local/bin/mtls-add-client.sh
#!/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."2. Auto-renewal script
This script checks for expiring certificates
, revokes them, re-signs the original CSR, and generates a new .p12 bundle.
/usr/local/bin/mtls-renew.sh
#!/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
fiSchedule this in crontab to run daily:
0 3 * * * /usr/local/bin/mtls-renew.sh >> /var/log/mtls-renew.log 2>&1Installing certificates on clients
Transfer the .p12 file securely
(AirDrop, USB, encrypted cloud) and install on each device.
- Transfer the
.p12file via AirDrop or save to Files app - Tap on the file in Files
- Go to Settings → Profile Downloaded (appears at top)
- Tap Install and enter your device passcode
- Enter the certificate export password
- The certificate is now installed. Safari will use it automatically when visiting the mTLS site
- Double-click the
.p12file - Keychain Access opens. Enter the export password
- Choose the login keychain (not System)
- Click Add
- Restart your browser
- When visiting the site, macOS prompts “Select a Certificate”
- Double-click the
.p12file - Certificate Import Wizard launches
- Select Current User (not Local Machine)
- Enter the password
- Select “Automatically select the certificate store”
- Click Finish
- Restart your browser
Edge and Chrome will now present the certificate when needed.
- Save
.p12to device storage - Go to Settings → Security → Encryption & credentials
- Tap Install a certificate → VPN & app user certificate
- Browse to the
.p12file - Enter the password and provide a name
- The certificate is installed for Chrome and other apps
For command-line/scripting access using PEM files:
# 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.comTroubleshooting guide
Common errors and solutions
| 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
Test mTLS connection with curl
Check Nginx error logs
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
Checklist
- Use 4096-bit RSA keys for CA (2048 minimum for clients)
- Enable CRL/OCSP to revoke compromised certificates
- Short client cert validity (1 year max, shorter for high security)
- TLS 1.2+ only, with strong ciphers
- Session caching for performance
- Monitor expiry and automate renewal
- Separate CAs for different trust levels if needed
- Log client DNs for audit trails
- Regular rotation of client certificates
Deep Dive
Official Documentation
- Nginx ngx_http_ssl_module — All SSL/TLS directives
- OpenSSL CA Documentation — Certificate authority commands
Tutorials & Guides
- Smallstep Hello mTLS — Interactive mTLS tutorial
- SSL.com mTLS Guide — IoT and user authentication
- Cloudflare: What is mTLS? — Conceptual overview