How to Implement Mutual TLS Authentication with Nginx

How to Implement Mutual TLS Authentication with Nginx

What You’ll Need

Table of Contents

Understanding Mutual TLS Architecture

Standard Transport Layer Security (TLS) is unidirectional. When you connect to a secure website, your browser checks the server certificate to confirm the identity of the host. However, the server accepts connections from any client capable of completing the cryptographic handshake.

Mutual TLS (mTLS) transforms this trust model into a two-way street. In an mTLS architecture, the server presents its certificate to validate its identity to the client, and the client must present its own cryptographic certificate to prove its identity to the server. If the client fails to provide a certificate signed by an explicit Certificate Authority (CA) trusted by the server, Nginx drops the connection before any application code executes.

I use mTLS extensively for machine-to-machine communication, internal microservices, and securing public webhooks. It removes the risk of compromised API keys, header spoofing, or brute-force authentication attacks against login routes. When running high-throughput edge nodes on a Hetzner VPS, offloading client verification directly to Nginx keeps backend servers lightweight and entirely isolated from unauthorized traffic.

The verification flow operates at layer 6 of the OSI model:

  1. The client initiates a TCP handshake on port 443.
  2. Nginx presents its server certificate to the client.
  3. Nginx issues a CertificateRequest payload containing acceptable CA distinguished names.
  4. The client submits its signed client certificate along with a cryptographic signature.
  5. Nginx verifies the client signature against its local trust store.
  6. If valid, Nginx proxies the request to the upstream application and injects client identity details into custom HTTP headers.

Creating Your Private Certificate Authority

To validate client certificates without paying public certificate vendors for every internal service, you must establish an internal Certificate Authority (CA). This CA issues and signs both server and client certificates.

Log into your server shell and set up a dedicated directory structure for key management. We will enforce restricted file permissions to ensure private keys remain unreadable by unauthorized system users.

mkdir -p /etc/nginx/mtls
cd /etc/nginx/mtls
chmod 700 /etc/nginx/mtls

Next, create an OpenSSL configuration file named ca.cnf to define your CA properties, extensions, and default validity windows without relying on interactive prompts.

[ req ]
default_bits        = 4096
distinguished_name  = req_distinguished_name
prompt              = no
x509_extensions     = v3_ca

[ req_distinguished_name ]
countryName         = US
stateOrProvinceName = Virginia
localityName        = Reston
organizationName    = Infrastructure Automation Lab
commonName          = Internal Master Certificate Authority

[ v3_ca ]
subjectKeyIdentifier   = hash
authorityKeyIdentifier = keyid:always,issuer
basicConstraints       = critical, CA:true
keyUsage               = critical, digitalSignature, cCertSign, cRLSign

Execute the following commands to generate the 4096-bit RSA private key for your CA and create the self-signed root certificate valid for 3650 days:

openssl genrsa -out ca.key 4096
chmod 400 ca.key

openssl req -new -x509 -days 3650 -config ca.cnf -key ca.key -out ca.crt
chmod 444 ca.crt

You now possess a functional Certificate Authority consisting of ca.key (the private key used exclusively to sign certificates) and ca.crt (the public certificate that Nginx uses to verify clients).

Generating Server and Client Certificates

With your private CA operational, you must issue two distinct certificate bundles: one for the Nginx web server itself, and one for the external client application that needs access.

1. The Server Certificate

Create an OpenSSL configuration named server.cnf specifying your server domain name or primary IP address.

[ req ]
default_bits        = 2048
distinguished_name  = req_distinguished_name
prompt              = no
req_extensions      = v3_req

[ req_distinguished_name ]
countryName         = US
stateOrProvinceName = Virginia
localityName        = Reston
organizationName    = Infrastructure Automation Lab
commonName          = api.example.com

[ v3_req ]
basicConstraints     = CA:FALSE
keyUsage             = critical, digitalSignature, keyEncipherment
extendedKeyUsage     = serverAuth
subjectAltName       = @alt_names

[ alt_names ]
DNS.1 = api.example.com
IP.1  = 192.168.1.100

Run the commands below to generate the server key, produce a Certificate Signing Request (CSR), and sign the server certificate using your root CA:

openssl genrsa -out server.key 2048
chmod 400 server.key

openssl req -new -config server.cnf -key server.key -out server.csr

openssl x509 -req -in server.csr -CA ca.crt -CAkey ca.key -CAcreateserial -out server.crt -days 825 -extfile server.cnf -extensions v3_req
chmod 444 server.crt

2. The Client Certificate

Next, issue a certificate specifically assigned to a single client identity, such as an automated script or a remote microservice.

Create a configuration file named client1.cnf:

[ req ]
default_bits        = 2048
distinguished_name  = req_distinguished_name
prompt              = no
req_extensions      = v3_req

[ req_distinguished_name ]
countryName         = US
stateOrProvinceName = Virginia
localityName        = Reston
organizationName    = External API Consumer
commonName          = worker-node-01

[ v3_req ]
basicConstraints     = CA:FALSE
keyUsage             = critical, digitalSignature
extendedKeyUsage     = clientAuth

Execute the signing procedure to produce client1.key and client1.crt:

openssl genrsa -out client1.key 2048
chmod 400 client1.key

openssl req -new -config client1.cnf -key client1.key -out client1.csr

openssl x509 -req -in client1.csr -CA ca.crt -CAkey ca.key -CAcreateserial -out client1.crt -days 365 -extfile client1.cnf -extensions v3_req
chmod 444 client1.crt

To simplify client distribution across mobile apps or web tools, export the client key and certificate into a single PKCS#12 bundle:

openssl pkcs12 -export -out client1.p12 -inkey client1.key -in client1.crt -certfile ca.crt -passout pass:SecureExportPassword123

Configuring Nginx for Client Verification

Now, configure Nginx to enforce mTLS verification. We will instruct Nginx to demand a client certificate, validate it using ca.crt, and forward the certificate identity properties to our backend service via HTTP headers.

Open your main server configuration block in /etc/nginx/conf.d/mtls-api.conf and populate it completely:

server {
    listen 443 ssl http2;
    server_name api.example.com;

    # Server TLS Credentials
    ssl_certificate /etc/nginx/mtls/server.crt;
    ssl_certificate_key /etc/nginx/mtls/server.key;

    # SSL Protocol Optimization
    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;
    ssl_prefer_server_ciphers on;
    ssl_session_cache shared:SSL:10m;
    ssl_session_timeout 1d;

    # Client Certificate Verification Directives
    ssl_client_certificate /etc/nginx/mtls/ca.crt;
    ssl_verify_client on;
    ssl_verify_depth 2;

    # Custom Logging Format Including Client DN
    access_log /var/log/nginx/mtls_access.log combined;
    error_log /var/log/nginx/mtls_error.log notice;

    location / {
        proxy_pass http://127.0.0.1:8000;
        
        # Standard Proxy Headers
        proxy_set_header Host $host;
        proxy_set_header X-Real-IP $remote_addr;
        proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
        proxy_set_header X-Forwarded-Proto $scheme;

        # Inject Client Cryptographic Metadata into Upstream Headers
        proxy_set_header X-SSL-Client-Verify $ssl_client_verify;
        proxy_set_header X-SSL-Client-DN $ssl_client_s_dn;
        proxy_set_header X-SSL-Client-Serial $ssl_client_serial;
        proxy_set_header X-SSL-Client-Fingerprint $ssl_client_fingerprint;
    }
}

Validate the Nginx configuration syntax and reload the system service:

nginx -t
systemctl reload nginx

💡 Fast-Track Your Project: Don’t want to configure this yourself? I build custom n8n pipelines and bots. Message me with code SYS3-HUGO.

Testing mTLS and Revoking Certificates

With Nginx fully configured and running, you can test both unauthenticated rejection and valid authenticated access.

Testing Rejection via cURL

Attempt to request the host without passing client credentials:

curl -v https://api.example.com

Nginx terminates the TLS handshake immediately before returning any HTTP body payload. The command output confirms the missing credential rejection:

* TLSv1.3 (OUT), TLS handshake, Client hello (1):
* TLSv1.3 (IN), TLS handshake, Server hello (2):
* TLSv1.3 (IN), TLS handshake, Request CERT (13):
* TLSv1.3 (IN), TLS handshake, Certificate (11):
* TLSv1.3 (IN), TLS handshake, Server key exchange (12):
* TLSv1.3 (IN), TLS handshake, Server finished (20):
* TLSv1.3 (OUT), TLS alert, handshake failure (552):
curl: (35) OpenSSL SSL_connect: SSL_ERROR_SYSCALL in connection to api.example.com:443

Testing Authorization via cURL

Pass your client certificate, client key, and CA certificate directly into cURL:

curl --cacert /etc/nginx/mtls/ca.crt --cert /etc/nginx/mtls/client1.crt --key /etc/nginx/mtls/client1.key https://api.example.com

Nginx verifies client1.crt against ca.crt, passes the request to http://127.0.0.1:8000, and returns a HTTP 200 success response.

Automated Python Test Implementation

If you are calling this endpoint from remote automation jobs, configure your client runtime to supply certificate authority files alongside private keys. When building distributed Python task schedulers with Dramatiq, securing worker communication to upstream APIs via mTLS ensures that compromised node credentials cannot leak central database state. Similarly, if your worker threads handle tasks such as handling proxy rotation in web scraping pipelines, wrapping your internal management traffic with client certificate checks guarantees that scraping nodes communicate exclusively with verified upstream proxy controllers.

Here is a full, non-truncated Python client script using the standard requests module:

import requests

url = "https://api.example.com/v1/telemetry"
ca_cert_path = "/path/to/ca.crt"
client_cert_path = "/path/to/client1.crt"
client_key_path = "/path/to/client1.key"

payload = {
    "node_id": "worker-node-01",
    "status": "active",
    "tasks_processed": 1420
}

try:
    response = requests.post(
        url,
        json=payload,
        verify=ca_cert_path,
        cert=(client_cert_path, client_key_path),
        timeout=10
    )
    print(f"Status Code: {response.status_code}")
    print(f"Response Body: {response.text}")
except requests.exceptions.SSLError as ssl_error:
    print(f"mTLS Authentication Failed: {ssl_error}")
except requests.exceptions.RequestException as req_error:
    print(f"Network Transport Error: {req_error}")

If you operate real-time messaging integrations or need to monitor server telemetry securely, you can pipe alert events into external bots. If your backend architecture relies on webhooks while handling Telegram bot rate limits with Redis, protecting those incoming webhook receivers with mTLS at the Nginx edge stops third parties from spoofing status payload updates to your queue processors.

Revoking Compromised Client Certificates

If a client key is lost or leaked, you must revoke the certificate without rotating your entire root Certificate Authority. This is accomplished by issuing a Certificate Revocation List (CRL).

  1. Generate a database index file and serial tracker required by OpenSSL for certificate state tracking:
touch /etc/nginx/mtls/index.txt
echo 1000 > /etc/nginx/mtls/crlnumber
  1. Create an OpenSSL CA configuration file named ca_manage.cnf that enables revocation management:
[ ca ]
default_ca = CA_default

[ CA_default ]
dir               = /etc/nginx/mtls
certs             = $dir
crl_dir           = $dir
database          = $dir/index.txt
new_certs_dir     = $dir
certificate       = $dir/ca.crt
serial            = $dir/serial
crlnumber         = $dir/crlnumber
crl               = $dir/ca.crl
private_key       = $dir/ca.key
default_days      = 365
default_crl_days  = 30
default_md        = sha256
preserve          = no
policy            = policy_loose

[ policy_loose ]
countryName             = optional
stateOrProvinceName     = optional
localityName            = optional
organizationName        = optional
organizationalUnitName  = optional
commonName              = supplied
emailAddress            = optional
  1. Revoke client1.crt and update your CRL file:
openssl ca -config ca_manage.cnf -revoke /etc/nginx/mtls/client1.crt
openssl ca -config ca_manage.cnf -gencrl -out /etc/nginx/mtls/ca.crl
chmod 444 /etc/nginx/mtls/ca.crl
  1. Update your Nginx configuration block in /etc/nginx/conf.d/mtls-api.conf to include the ssl_crl parameter:
ssl_client_certificate /etc/nginx/mtls/ca.crt;
ssl_crl /etc/nginx/mtls/ca.crl;
ssl_verify_client on;

Reload Nginx to enforce the revocation immediately:

systemctl reload nginx

Any subsequent connection attempt presenting client1.crt will be rejected at the SSL handshake stage with a 400 Bad Request or SSL connection error.

Getting Started

To implement zero-trust mutual TLS in your infrastructure:

  1. Spin up an edge instance using Hetzner VPS or DigitalOcean.
  2. Point your domain DNS records registered via Namecheap to your proxy IP address.
  3. Configure your private Root CA and issue client certificates following the OpenSSL commands listed above.
  4. Deploy the complete Nginx configuration file to manage edge authentication automatically.

Outsource Your Automation

Don’t have time? I build production n8n workflows, WhatsApp bots, and fully automated YouTube Shorts pipelines. Hire me on Fiverr, mention SYS3-HUGO for priority. Or DM at chasebot.online.

Want to automate this yourself?

Start with n8n Cloud (free tier available) or self-host on a Hetzner VPS for full control.

Want this engine running on your own VPS?

This blog publishes itself — daily, unattended, on free API tiers. The full engine, Hugo theme, and setup guide are available as System 3.

Get System 3
system online