How to Protect Private Sites with mTLS (Mutual TLS)
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 certssl_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)#
- Go to
chrome://settings/certificates - Click the “Your certificates” tab
- Click Import
- Select your
.p12file - Enter the password you set in Step 2
- Restart Chrome completely (close all windows)
- Visit your site β Chrome will prompt you to select the certificate
Firefox#
- Go to
about:preferences#privacy - Scroll to Certificates β View Certificates
- Your Certificates tab β Import
- Select the
.p12file, enter password
macOS (Safari / system-wide)#
- Double-click the
.p12file - Keychain Access opens β import to your login keychain
- Find the cert, double-click it, set Trust β Always Trust
Android#
- Transfer the
.p12file to your phone - Settings β Security β Install a certificate β VPN & app user certificate
- Select the file, enter password
iPhone / iPad#
- AirDrop or email the
.p12file to yourself - Open it β Install Profile
- 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#
- Create a CA β your trust anchor (
ca.key+ca.crt) - Generate client certs β one per device, bundled as
.p12 - Configure nginx β two lines:
ssl_client_certificateandssl_verify_client on - Install the cert in your browser
- 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.