Back to Blog
José Manuel Requena Plens

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.

Cover image for Mastering Mutual TLS (mTLS) with Nginx: A Deep Dive

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.

mTLS vs Other Authentication Methods
MethodPhishable?Credential StorageAutomationZero Trust
Username/PasswordYesServer databaseEasyNo
API KeysYes (if exposed)Server + clientEasyNo
Password + 2FAPartiallyServer + authenticatorHardPartial
mTLSNoHardware/keychainMediumYes

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

One-Way TLS vs Mutual TLS

Understanding the difference is fundamental:

One-Way TLS vs Mutual TLS
AspectOne-Way TLS (Standard HTTPS)Mutual TLS (mTLS)
Server authenticates toClient (browser)Client (browser)
Client authenticates toNot verifiedServer verifies client cert
Certificate requirementServer onlyBoth server and client
Use casePublic websitesAPIs, 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:

Nginx ServerClientNginx ServerClientTCP Connection Established"Send me YOUR certificate"Verification PipelineEncrypted Session BeginsConnection Terminatedalt[All Checks Pass][Any Check Fails]ClientHello (TLS version, cipher suites)ServerHello + Server CertificateCertificateRequestClient CertificateCertificateVerify (signature proof)Finished1. Validate CA signature chain2. Check against CRL/OCSP3. Verify CN/SAN if requiredFinished + 200 OK400 Bad Request / 403 Forbidden
Nginx mTLS Handshake Flow

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

# 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

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:

CA Configuration Choices
ParameterRecommendationRationale
Key Size4096-bit RSA or P-384 ECDSAStronger security margin against classical attacks
Validity10 years for CALonger than any client cert
Hash AlgorithmSHA-256 minimumSHA-1 is deprecated
ExtensionsCA:TRUE, keyUsage:keyCertSign,cRLSignRequired for signing certs and CRLs

Create the OpenSSL configuration

Create a comprehensive OpenSSL configuration file:

/​etc/​nginx/​pki/​openssl.cnf
# =============================================================
# 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
DirectiveValue/TypeDescription
dirPath (String)Base directory for your PKI (e.g., /etc/nginx/pki).
databaseFile PathText file (index.txt) tracking all issued and revoked certificates.
new_certs_dirDirectory PathWhere copies of every issued certificate are archived by serial number.
certificateFile PathThe CA’s own public certificate used to sign others.
private_keyFile PathThe CA’s private key. Must be protected.
default_daysInteger (Days)Default validity period for issued certificates (e.g., 365).
policySection NameWhich policy section to enforce for matching DN fields (e.g., policy_loose).
copy_extensionscopy | noneCrucial: 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
FieldRuleMeaning
countryNameoptionalThe client doesn’t need to specify a country.
commonNamesuppliedRequired. The client must provide a Common Name (used for identification).
organizationNameoptionalCan be left empty.

3. Request Settings ([ req ])

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

Request Defaults
DirectiveValueDescription
default_bitsInteger (e.g., 4096)Default key size if not specified.
distinguished_nameSection NamePoints to the section defining default DN values ([ req_distinguished_name ]).
x509_extensionsSection NameExtensions 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/DirectiveValueEffect
[ v3_ca ] - For the Root CA
basicConstraintscritical, CA:TRUEIdentity: Marks this cert as a Certificate Authority that can sign others.
keyUsagekeyCertSign, cRLSignPermissions: Allowed to sign certificates and CRLs.
[ v3_client ] - For User Devices
basicConstraintscritical, CA:FALSERestriction: This certificate cannot be used to sign other certificates.
extendedKeyUsageclientAuthPurpose: Valid only for authenticating a client to a server (mTLS).

Generate the CA key and certificate

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

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

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)
AspectCommon Name (CN)Subject Alternative Name (SAN)
StandardLegacy (deprecated for server certs)Modern, RFC 6125 compliant
Multiple identitiesNoYes (DNS, email, URI)
Best forSimple client identificationServices, 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

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

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

Certificate Format Comparison
FormatContainsBest ForPassword Protected
PEM (.crt, .key)Separate text filesLinux servers, scriptingOptional
PKCS12 (.p12, .pfx)All-in-one binary bundleBrowsers, mobile, WindowsRequired

Create PKCS12 bundle for clients

# 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

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:

ssl_verify_client Options
ValueBehaviorUse Case
onRequire valid cert; reject withoutStrict access control (recommended)
optionalAccept if valid; proceed withoutHybrid auth, optional verification
optional_no_caAccept any cert, no CA validationDevelopment/testing only
offNo client certificate requiredStandard HTTPS

Complete Nginx configuration

/​etc/​nginx/​sites-available/​secure.example.com.conf
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

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

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

TLS protocol and cipher configuration

Proper TLS configuration is critical for security.

Here’s what each setting does:

Protocol version selection

TLS Protocol Versions
VersionStatusSecurity
TLS 1.0DeprecatedVulnerable (BEAST, POODLE)
TLS 1.1DeprecatedWeak ciphers, no AEAD
TLS 1.2SupportedSecure with proper ciphers
TLS 1.3RecommendedBest security, faster handshake
TLS Security Settings
# 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:

Nginx mTLS Variables
VariableContentExample
$ssl_client_verifyVerification resultSUCCESS, FAILED:reason
$ssl_client_s_dnSubject DNCN=iphone-jmrp
$ssl_client_i_dnIssuer DNCN=JMRP-IO-LAB Root CA
$ssl_client_fingerprintSHA1 fingerprintAB:CD:EF:12:34:…
$ssl_client_serialCertificate serial1000

CN-based authorization with map

Use the map directive to create an allowlist:

/​etc/​nginx/​conf.d/​mtls-access.conf
# 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
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

/​etc/​nginx/​nginx.conf
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:

CRL vs OCSP Comparison
AspectCRL (Certificate Revocation List)OCSP (Online Certificate Status)
How it worksServer downloads full list of revoked certsServer queries responder for single cert
TimelinessDelayed (depends on CRL refresh interval)Near real-time
BandwidthHigher (downloads entire list)Lower (single query)
Nginx directivessl_crlssl_stapling (for server cert)
ComplexitySimple (file on disk)Requires OCSP responder service

Step 1: revoke a certificate

cd /etc/nginx/pki # Revoke the certificate sudo openssl ca -config openssl.cnf \ -revoke certs/lost-device.crt

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

Step 2: generate the CRL file

# 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
# Enable CRL checking
ssl_crl /etc/nginx/pki/crl/ca.crl;
sudo nginx -t && sudo systemctl reload nginx

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

No

Yes

Yes

Generate Key & CSR

Sign with CA

Export to PKCS12

Secure Distribution

Install on Device

Monitor Expiration

Expired?

Renew

Compromised?

Revoke & Update CRL

Certificate Lifecycle Flow

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."
# 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.

/​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
fi

Schedule this in crontab to run daily:

crontab -e
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.

  1. Transfer the .p12 file via AirDrop or save to Files app
  2. Tap on the file in Files
  3. Go to SettingsProfile 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
  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”
  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.

  1. Save .p12 to device storage
  2. Go to SettingsSecurityEncryption & credentials
  3. Tap Install a certificateVPN & 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

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.com

Troubleshooting guide

Common errors and solutions

mTLS Error Diagnosis
ErrorCauseSolution
400 No required SSL certificate was sentBrowser didn’t send a certificateCheck certificate is installed; restart browser; try incognito mode
400 The SSL certificate errorCertificate validation failedVerify ssl_client_certificate points to correct CA
403 ForbiddenCert valid but not authorizedCheck CRL or map rules; examine error_log
Certificate selection loopOS keeps promptingTrust the CA in keychain; restart browser
ssl_client_verify: FAILEDChain validation failedEnsure full chain in ssl_client_certificate

Debugging commands

Verify certificate against CA

# 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

# 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

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

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

  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

Tutorials & Guides

Tools

  • step-ca — Modern certificate authority
  • cfssl — Cloudflare’s PKI toolkit
  • easy-rsa — Simple CA management scripts