Mutual TLS (mTLS) makes your browser present a certificate to the server β€” like an SSH key, but for websites. No cert, no access. This guide walks you through setting it up with nginx from scratch.

The Problem#

You’ve got a private dashboard, admin panel, or internal tool running on a VPS. How do you lock it down?

  • Passwords? Can be phished, brute-forced, or forgotten.
  • VPN? Overhead, always-on connection, another service to maintain.
  • IP whitelisting? Breaks when your IP changes.

What if the server could verify your device before even showing a login page?

What mTLS Is#

Normal HTTPS (TLS) is one-way: your browser verifies the server’s certificate to confirm you’re talking to the real site. Mutual TLS adds the reverse β€” the server also verifies your browser’s certificate.

Normal TLS:
  Browser  ──verifies──▢  Server cert βœ“

Mutual TLS:
  Browser  ──verifies──▢  Server cert βœ“
  Server   ──verifies──▢  Client cert βœ“

If the browser doesn’t present a valid client certificate, nginx immediately returns 400 Bad Request β€” the connection is rejected at the TLS layer, before any application code runs. There’s no login page to attack.

What You Need#

  • A Linux server with nginx and a site you want to protect
  • OpenSSL (installed on virtually every Linux system)
  • 10 minutes

Step 1: Create Your Own Certificate Authority (CA)#

The CA is the trust anchor. It signs client certificates, and nginx trusts anything signed by this CA.

# Create a directory for your certs
sudo mkdir -p /etc/nginx/mtls
cd /etc/nginx/mtls

# Generate a 4096-bit CA private key
sudo openssl genrsa -out ca.key 4096

# Create the CA certificate (valid for 10 years)
sudo openssl req -new -x509 -days 3650 -key ca.key -out ca.crt \
  -subj "/CN=My Private CA"

What this creates:

  • ca.key β€” your CA’s private key. Guard this. Anyone with it can create valid client certs.
  • ca.crt β€” the CA certificate. This goes into nginx so it knows which clients to trust.

Step 2: Generate a Client Certificate#

This is what gets installed on your device. Each device/person can get their own cert.

cd /etc/nginx/mtls

# Generate client private key
sudo openssl genrsa -out client.key 4096

# Create a certificate signing request (CSR)
sudo openssl req -new -key client.key -out client.csr \
  -subj "/CN=my-device-name"

# Sign it with your CA (valid for 1 year)
sudo openssl x509 -req -days 365 -in client.csr \
  -CA ca.crt -CAkey ca.key -CAcreateserial -out client.crt

Now bundle it into a .p12 file that browsers can import:

sudo openssl pkcs12 -export -out my-device.p12 \
  -inkey client.key -in client.crt -certfile ca.crt \
  -passout pass:your-password-here

Important: Use a real password here. You’ll need it when importing into your browser.

Step 3: Configure nginx#

Add two lines to your site’s server block:

server {
    server_name private.example.com;

    # === mTLS: require client certificate ===
    ssl_client_certificate /etc/nginx/mtls/ca.crt;
    ssl_verify_client on;

    location / {
        proxy_pass http://127.0.0.1:3000;
        proxy_http_version 1.1;
        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;
    }

    listen 443 ssl;
    ssl_certificate /etc/letsencrypt/live/private.example.com/fullchain.pem;
    ssl_certificate_key /etc/letsencrypt/live/private.example.com/privkey.pem;
}

The two critical lines:

  • ssl_client_certificate β€” points to your CA cert
  • ssl_verify_client on β€” enforces client cert verification

Test and reload:

sudo nginx -t
sudo systemctl reload nginx

That’s it. Anyone visiting your site without a valid client certificate now gets:

400 Bad Request
No required SSL certificate was sent

Step 4: Install the Certificate in Your Browser#

Chrome / Edge (Desktop)#

  1. Go to chrome://settings/certificates
  2. Click the “Your certificates” tab
  3. Click Import
  4. Select your .p12 file
  5. Enter the password you set in Step 2
  6. Restart Chrome completely (close all windows)
  7. Visit your site β€” Chrome will prompt you to select the certificate

Firefox#

  1. Go to about:preferences#privacy
  2. Scroll to Certificates β†’ View Certificates
  3. Your Certificates tab β†’ Import
  4. Select the .p12 file, enter password

macOS (Safari / system-wide)#

  1. Double-click the .p12 file
  2. Keychain Access opens β€” import to your login keychain
  3. Find the cert, double-click it, set Trust β†’ Always Trust

Android#

  1. Transfer the .p12 file to your phone
  2. Settings β†’ Security β†’ Install a certificate β†’ VPN & app user certificate
  3. Select the file, enter password

iPhone / iPad#

  1. AirDrop or email the .p12 file to yourself
  2. Open it β†’ Install Profile
  3. Settings β†’ General β†’ VPN & Device Management β†’ trust the profile

Step 5: Verify It Works#

From a machine without the certificate:

curl https://private.example.com
# Returns: 400 Bad Request

From a machine with the certificate:

curl --cert client.crt --key client.key https://private.example.com
# Returns: your site content

Or just open the site in your browser. If the cert is installed correctly, the browser will prompt you to select it, and the site loads normally.

Important: Cloudflare Compatibility#

mTLS does not work behind Cloudflare’s proxy (orange cloud).

Here’s why: when Cloudflare proxies your traffic, the TLS connection is:

Browser  ──TLS──▢  Cloudflare  ──TLS──▢  Your Server

The client certificate is presented to Cloudflare, not to your nginx. Cloudflare’s free plan doesn’t forward client certificates to your origin.

The fix: Switch the DNS record to DNS only (grey cloud) so traffic goes directly to your server:

Browser  ──TLS + client cert──▢  Your Server (nginx)

You can do this per-subdomain. Keep your public sites behind Cloudflare, and set private sites to DNS-only.

Managing Multiple Devices#

One CA can sign unlimited client certificates. Each device gets its own:

cd /etc/nginx/mtls

# Generate cert for another device
sudo openssl genrsa -out phone.key 4096
sudo openssl req -new -key phone.key -out phone.csr -subj "/CN=my-phone"
sudo openssl x509 -req -days 365 -in phone.csr \
  -CA ca.crt -CAkey ca.key -CAcreateserial -out phone.crt
sudo openssl pkcs12 -export -out phone.p12 \
  -inkey phone.key -in phone.crt -certfile ca.crt \
  -passout pass:phone-password

To revoke a device: The simplest approach for small setups is to regenerate the CA and re-issue certs to devices you still trust. For larger setups, look into Certificate Revocation Lists (CRLs) or OCSP.

Protecting Multiple Sites with One Certificate#

Since nginx validates against the CA (not individual certs), one client certificate works for all sites that trust the same CA. Just add the same two lines to each site’s nginx config:

ssl_client_certificate /etc/nginx/mtls/ca.crt;
ssl_verify_client on;

One cert on your device, all your private sites protected.

Optional: Allow Some Routes Without a Certificate#

If you need certain paths to be public (like an API that other services call):

ssl_verify_client optional;

location / {
    if ($ssl_client_verify != SUCCESS) {
        return 403;
    }
    proxy_pass http://127.0.0.1:3000;
}

location /api/health {
    # No cert required for health checks
    proxy_pass http://127.0.0.1:3000;
}

Why mTLS Over Alternatives#

mTLS VPN Password IP Whitelist
Phishing-proof βœ… βœ… ❌ βœ…
No always-on connection βœ… ❌ βœ… βœ…
Works on mobile βœ… ⚠️ βœ… ❌
Per-device revocation βœ… βœ… ❌ ❌
Zero app changes βœ… βœ… ❌ βœ…
Blocks before app layer βœ… βœ… ❌ βœ…

Summary#

  1. Create a CA β€” your trust anchor (ca.key + ca.crt)
  2. Generate client certs β€” one per device, bundled as .p12
  3. Configure nginx β€” two lines: ssl_client_certificate and ssl_verify_client on
  4. Install the cert in your browser
  5. Set DNS to direct (not proxied) if using Cloudflare

Your private sites are now invisible to the internet. No login page, no password to crack, no VPN to maintain. If your device has the cert, you’re in. If it doesn’t, you don’t even get a response.


This guide was written after implementing mTLS on a Hetzner VPS running multiple private tools behind nginx. The entire setup took about 10 minutes.