mTLS: client certificates with nginx
Lock an admin panel behind mTLS: build a private CA with openssl, issue client certificates, and configure nginx to refuse anyone without one.
What mTLS does
Mutual TLS, usually written mTLS, makes nginx ask every client for a certificate and refuse the request when that certificate is missing or was not issued by a certificate authority (CA) you control. The check happens inside the TLS (transport layer security) handshake, so a caller without a valid client certificate never reaches your application at all. That is the appeal: an admin panel or a metrics endpoint can sit on the public internet with no login page and nothing for a bot to guess.
The build is small. One private CA made with openssl, one certificate per person, three directives in the nginx server block. The work that decides whether this survives a year is operational, so most of this guide covers lifetimes, revocation, per-person certificates, and what to do when a client is refused and nobody can see why.
Two chains, not one
There are two certificate chains in an mTLS setup and they have nothing to do with each other. Collapsing them is the first mistake almost everyone makes.
The first chain is the server's. Your VPS presents a certificate for admin.example.com issued by a public CA such as Let's Encrypt, and the browser checks it against the root store that ships with the operating system. Nothing about mTLS changes that half. If certbot issues that certificate for you today, keep it exactly as it is: see issuing a Let's Encrypt certificate for nginx with certbot.
The second chain is the client's. You create a small CA of your own, you sign one certificate for each person who needs in, and you tell nginx to trust that CA and only that CA when checking clients. No public root store knows your CA and none needs to. The single party that has to trust it is nginx, through the ssl_client_certificate file.
So ssl_client_certificate never affects the certificate nginx presents, and the Let's Encrypt chain never affects which clients are allowed in. Pointing ssl_client_certificate at fullchain.pem does not do what it looks like it does: that directive names the issuers a client certificate may come from, which is the other end of the connection. Making the server itself trust your CA for its own outbound work is a separate job, covered in adding your own CA to the Ubuntu trust store, and the system trust store is not what nginx reads when it verifies a client.
Build your own client CA with openssl
Build the CA somewhere other than the web server. nginx needs only the CA's public certificate. The CA private key signs new client certificates, so leaving it on an internet-facing box means one break-in gives the attacker the power to mint valid clients for himself or herself at will.
mkdir -p ~/client-ca/certs ~/client-ca/newcerts ~/client-ca/private ~/client-ca/csr
cd ~/client-ca
chmod 700 private
touch index.txt
echo 1000 > serial
echo 1000 > crlnumberindex.txt, serial and crlnumber are the CA database. openssl ca refuses to run without them. They are also what makes revocation possible later, because a revocation list names serial numbers, so the CA has to remember which serial went to whom.
Write ~/client-ca/openssl.cnf. Set dir to the real path of that directory, since openssl ca does not expand ~.
[ ca ]
default_ca = client_ca
[ client_ca ]
dir = /home/you/client-ca
database = $dir/index.txt
new_certs_dir = $dir/newcerts
certificate = $dir/ca.crt
private_key = $dir/private/ca.key
serial = $dir/serial
crlnumber = $dir/crlnumber
default_md = sha256
default_days = 365
default_crl_days = 30
policy = policy_loose
rand_serial = no
unique_subject = no
email_in_dn = no
[ policy_loose ]
commonName = supplied
countryName = optional
stateOrProvinceName = optional
organizationName = optional
organizationalUnitName = optional
emailAddress = optional
[ client_ext ]
basicConstraints = CA:FALSE
keyUsage = critical, digitalSignature, keyEncipherment
extendedKeyUsage = clientAuth
subjectKeyIdentifier = hash
authorityKeyIdentifier = keyid,issuerNow the CA key and its self-signed certificate:
openssl genrsa -aes256 -out private/ca.key 4096
chmod 600 private/ca.key
openssl req -x509 -new -key private/ca.key -sha256 -days 3650 \
-subj "/O=Example Ops/CN=Example Ops Client CA" \
-addext "basicConstraints=critical,CA:TRUE,pathlen:0" \
-addext "keyUsage=critical,keyCertSign,cRLSign" \
-out ca.crt-aes256 puts a passphrase on the CA key, so every signing run asks for it. That is the point of it. Check what you made:
openssl x509 -in ca.crt -noout -subject -dates -ext basicConstraintsThe subject should be your CA and the validity should run ten years. The extension line should read CA:TRUE, pathlen:0. pathlen:0 means this CA may sign end certificates and may not sign another CA, which keeps the chain exactly one level deep and lets you leave ssl_verify_depth alone.
Issue a client certificate per person
One certificate per person. Never one shared certificate for a team, because a shared certificate cannot be revoked without locking everyone out, and it tells you nothing about who called.
openssl genrsa -out private/alice.key 2048
openssl req -new -key private/alice.key -out csr/alice.csr \
-subj "/O=Example Ops/CN=alice"
openssl ca -config openssl.cnf -extensions client_ext \
-days 365 -notext -in csr/alice.csr -out certs/alice.crtopenssl ca prints the certificate it is about to sign, asks for the CA passphrase, asks twice for confirmation, then appends a line to index.txt. Add -batch when you script it. The client_ext section matters because of one line in it: extendedKeyUsage = clientAuth. A certificate that carries an extended key usage listing only serverAuth is rejected as unfit for client authentication, so state the purpose instead of hoping.
Verify the pair against the CA before you hand anything over:
openssl verify -CAfile ca.crt certs/alice.crtThat prints certs/alice.crt: OK. Any other output means the certificate and the CA do not match, and no nginx configuration will rescue it.
Bundle the key and the certificate into one file a browser can import:
openssl pkcs12 -export -inkey private/alice.key -in certs/alice.crt \
-name "alice at example ops" -out alice.p12The export asks for a password, which protects the file in transit. Send the file and the password over different channels, and hand people the .p12 rather than a bare .key. You can add -certfile ca.crt to include the CA in the bundle, but nginx does not need it: nginx already holds ca.crt, so a certificate signed directly by that CA verifies on its own.
OpenSSL 3, which Ubuntu 24.04 ships, writes PKCS#12 files with current encryption, and browsers and operating systems in use as of August 2026 read them. If an old importer refuses the file, re-export with -legacy added, which falls back to the older algorithms that importer expects. Read the message the importer gives you before reaching for that flag.
Configure nginx with ssl_client_certificate and ssl_verify_client
Copy the CA certificate, and only the CA certificate, to the server.
scp ca.crt user@admin.example.com:/tmp/client-ca.crt
ssh user@admin.example.com \
'sudo install -o root -g root -m 644 /tmp/client-ca.crt /etc/nginx/client-ca.crt'Mode 644 is correct here. A CA certificate is public information. The CA key stays on your workstation.
Then add three directives to the server block that already terminates TLS:
server {
listen 443 ssl;
server_name admin.example.com;
ssl_certificate /etc/letsencrypt/live/admin.example.com/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/admin.example.com/privkey.pem;
ssl_client_certificate /etc/nginx/client-ca.crt;
ssl_verify_client on;
ssl_verify_depth 1;
location / {
proxy_pass http://127.0.0.1:8080;
proxy_set_header Host $host;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
}ssl_verify_depth 1 is the nginx default, and it says the client certificate must be signed by the CA in that file directly. Raise it only if you add an intermediate. nginx also sends the subject names from ssl_client_certificate to the client during the handshake, which is how a browser knows which of its certificates to offer. That behaviour is the reason to use ssl_client_certificate rather than ssl_trusted_certificate, which verifies the same way but sends no list.
Ubuntu 24.04 ships nginx 1.24, where HTTP/2 goes on the listen line as listen 443 ssl http2;. On nginx 1.25.1 and later that form is deprecated and HTTP/2 is its own directive, http2 on;. Neither choice changes the certificate check.
Reload and read the result:
sudo nginx -t && sudo systemctl reload nginx
curl -i https://admin.example.com/nginx -t prints syntax is ok and test is successful. The curl call carries no certificate, so it should come back 400 Bad Request with the body No required SSL certificate was sent. That is nginx refusing at its own gate, which means the configuration is live and the application was never asked. Now try it properly:
curl --cert certs/alice.crt --key private/alice.key https://admin.example.com/That should return whatever your application serves.
Why the gate belongs in the server block
The certificate is exchanged during the TLS handshake, before nginx has read a request line, so at that moment nginx does not know which location the request will land in. Putting ssl_verify_client on; inside a location asks the client to renegotiate in the middle of the connection. TLS 1.3 removed renegotiation and HTTP/2 forbids it, so on a current stack that pattern fails instead of prompting.
Do the scoping yourself. Ask for a certificate at the server level, then decide per location:
ssl_verify_client optional;
location /metrics {
if ($ssl_client_verify != SUCCESS) { return 403; }
proxy_pass http://127.0.0.1:9090;
}
location /healthz {
proxy_pass http://127.0.0.1:8080;
}$ssl_client_verify holds SUCCESS, or NONE when the client sent nothing, or FAILED: followed by a reason. With optional, nginx requests a certificate and verifies it only if one arrives, which is what lets the public /healthz path above work while /metrics stays shut. A certificate that is sent and fails verification is still refused by nginx at that point. If you want to inspect a failing certificate yourself instead, that is optional_no_ca, and then your own test has to treat every value other than SUCCESS as a refusal.
nginx has non-standard status codes for this, and error_page can catch them so a refused visitor gets an explanation rather than a bare 400:
error_page 495 496 = @needcert;
location @needcert {
default_type text/plain;
return 200 "This host requires a client certificate. Ask ops for one.\n";
}495 means the client certificate failed verification. 496 means the client presented no certificate. Keep that page plain text, because the person reading it has no session and no account.
How do I install the client certificate in a browser?
Firefox keeps its own certificate store: Settings, then Privacy and Security, then View Certificates, then the Your Certificates tab, then Import, then pick the .p12 and type its password.
Chrome and Edge use the operating system store on Windows and macOS, so opening the .p12 file starts the system import wizard. On Linux, Chrome reads a separate NSS (network security services) database in your home directory, and the command line tool is the reliable route:
sudo apt install -y libnss3-tools
pk12util -d sql:$HOME/.pki/nssdb -i alice.p12Load the site afterwards and the browser asks which certificate to send. Chrome remembers that choice for the rest of the browser session, so restart the browser when you want to be asked again. The certificate lives in one browser profile on one machine, so a certificate imported into Firefox is invisible to Chrome, and both are invisible to your phone.
Testing with curl --cert
Debug with curl, because it reports what it did.
curl -v --cert certs/alice.crt --key private/alice.key https://admin.example.com/You can concatenate the certificate and the key into one PEM file and pass it as --cert alice.pem. If the key has a passphrase, curl prompts for it. It also accepts --cert alice.pem:passphrase, which then sits in your shell history, so take the prompt.
Two checks are worth running before you blame nginx. First, the certificate and the key must be a pair:
openssl x509 -noout -pubkey -in certs/alice.crt | openssl sha256
openssl pkey -pubout -in private/alice.key | openssl sha256Two identical hashes mean the files belong together. Two different hashes mean you mixed up two people's files, and no client will name that cause for you.
Second, the server should be asking for your CA:
openssl s_client -connect admin.example.com:443 -servername admin.example.com </dev/nullLook for the Acceptable client certificate CA names block in the output, and for your CA's subject inside it. If that block is missing entirely, nginx is not requesting a certificate on the server block that answered, so your directives landed in a different one, often the default server.
Passing the client CN to the application
The certificate says who called, but the application behind the proxy cannot see the TLS layer, so nginx has to pass the name along.
map $ssl_client_s_dn $client_cn {
default "";
"~,?CN=(?<cn>[^,]+)" $cn;
}$ssl_client_s_dn holds the subject distinguished name in RFC 2253 form, which looks like CN=alice,O=Example Ops. The map lifts the CN field into $client_cn. Keep the CN a plain username, because a comma inside a CN is escaped in that format and the small regular expression above does not handle the escape.
location / {
proxy_pass http://127.0.0.1:8080;
proxy_set_header Host $host;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_set_header X-Client-Cert-CN $client_cn;
proxy_set_header X-Client-Cert-Serial $ssl_client_serial;
}proxy_set_header replaces any header of that name the caller sent, so nobody can forge X-Client-Cert-CN through this location. Two conditions keep that true. nginx inherits proxy_set_header from the outer level only when the inner level defines none of its own, so a second location with one proxy_set_header line silently loses every header set above it, this one included. And the application must be unreachable except through nginx, which means binding it to 127.0.0.1 rather than 0.0.0.0, since an app on a public port will read the forged header straight from the internet. The proxy side of that is covered in an nginx reverse proxy configuration explained line by line. If the application wants the whole certificate instead of a name, $ssl_client_escaped_cert carries it URL-encoded and safe inside a header.
How do I revoke one client certificate?
Somebody leaves, or a laptop goes missing. You revoke that one certificate and everyone else keeps working, which is the entire reason for issuing one per person.
cd ~/client-ca
openssl ca -config openssl.cnf -revoke certs/alice.crt
openssl ca -config openssl.cnf -gencrl -out crl.pemThe first command flips that serial's line in index.txt from V to R. The second writes a certificate revocation list (CRL), a signed file naming revoked serial numbers. Ship it and point nginx at it with ssl_crl /etc/nginx/client-ca.crl; beside the other directives.
scp crl.pem user@admin.example.com:/tmp/client-ca.crl
ssh user@admin.example.com 'sudo install -m 644 /tmp/client-ca.crl /etc/nginx/client-ca.crl && sudo nginx -t && sudo systemctl reload nginx'Here is the trap that closes the gate on everybody. A CRL carries a nextUpdate date, set by default_crl_days, which is 30 in the config above. Once that date passes, OpenSSL treats the list as stale and fails verification for every client certificate with CRL has expired, not only for the revoked one. nginx reads the file when it loads its configuration, so a fresh CRL on disk changes nothing until a reload. Regenerate and reload on a schedule comfortably inside the window, weekly against 30 days, and check the dates before you copy:
openssl crl -in crl.pem -noout -lastupdate -nextupdateFor a handful of users there is a smaller option. The CA is yours, so nginx can refuse a serial directly and skip the CRL machinery:
map $ssl_client_serial $revoked {
default 0;
"1002" 1;
}Pair that with if ($revoked) { return 403; } in the location. It has no expiry date to forget. It also does not travel, so anything else trusting your CA knows nothing about it. For one nginx in front of one application it is the honest simple answer. Move to the CRL once there is more than one gate.
How long should client certificates live?
Give client certificates a year, or less if you can stand the reissue work. Expiry is the quiet failure here, because nothing warns the holder in advance. They open the panel one morning, nginx refuses the connection, and the browser describes the refusal in its own words, which rarely include the word expired. Keep the CA at ten years and put its expiry date somewhere you will actually read, because when the CA certificate expires every certificate under it stops verifying on the same day.
Two commands keep you ahead of that:
openssl x509 -in certs/alice.crt -noout -subject -serial -enddate
awk -F'\t' '{print $1, $2, $4}' ~/client-ca/index.txtThe first column of index.txt is the status: V for valid, R for revoked, E for expired. The second column is the expiry in YYMMDDHHMMSSZ form and the fourth is the serial. That file is your only record of who holds what, so back it up together with the CA key and treat both as secrets.
Renewal is a new certificate, not an extension. Generate a fresh key and CSR (certificate signing request), sign it, hand it over, then revoke the old one once the person confirms the new one works.
What mTLS protects against, and what it does not
What it removes is unauthenticated reach. A scanner that finds your hostname is refused during the handshake, so it never sends an HTTP request, never sees a login form, and never gets to try a stolen password against one. Credential stuffing has nothing to stuff. A vulnerability in the application's login flow is unreachable by anyone without a certificate. It also removes the shared secret people paste into chat, because a private key is a file that is awkward to copy by accident.
What it does nothing about is a compromised client. Malware on a laptop has the key file, and it has the passphrase the moment the owner types it. To the server that attacker looks exactly like a legitimate user, because a certificate proves possession of a file, not the presence of a person. The .p12 password and full disk encryption still carry weight.
It is also not authorization. Every valid certificate reaches everything that server block serves unless you check $client_cn and act on the value. By default two certificate holders have identical access.
And it guards only the path through nginx. If the application also listens on a public port, mTLS in front of it is decoration: bind the app to 127.0.0.1 and keep the firewall shut on its port. The other door into the same box is SSH, and it deserves the same attention, covered in hardening SSH access on your VPS.
One last limit, and it bites on the day you switch it on. Anything that cannot present a certificate stops working: an uptime monitor, a webhook from a payment provider, an RSS reader, a mobile app with no certificate store you can reach. Decide about those before you set ssl_verify_client on, because the failure is total and, on their side, silent.
When a client is refused, read what the client reports
The message a refused client shows depends on the browser, the curl version and the TLS library underneath, so read what your own client prints instead of matching it against a message written down somewhere else. The useful detail is on the server.
sudo tail -n 50 /var/log/nginx/error.logA rejected certificate leaves a line containing client SSL certificate verify error followed by the reason OpenSSL gave. That reason is the fact to act on. Usually it is one of a few things. The certificate came from a different CA than the file named in ssl_client_certificate. The certificate is outside its validity dates. The CRL on the server has passed its nextUpdate, so it now fails every client rather than one.
When the browser never offers a certificate at all, the problem sits earlier than verification. nginx sends the acceptable issuer names during the handshake, and the browser found nothing in its store that matches, so it had nothing to offer you. Import the .p12 again, into the profile you are really browsing with.
One more case worth naming. If you tested with a lone self-signed client certificate rather than one your CA signed, verification cannot pass, because nginx checks the signature against the CA file and a self-signed certificate is not in it. The mechanics of making the certificate are the same as in generating a self-signed certificate on Ubuntu. mTLS just needs the extra step where your CA signs it.
FAQ
Do I still need a Let's Encrypt certificate if I use mTLS?
Yes. The two certificates are unrelated. Your server presents its own certificate so the browser trusts the hostname, and that one still has to come from a CA the browser already knows. Your client CA is a separate private chain, used only to check who is connecting. Setting ssl_client_certificate changes nothing about the certificate nginx presents, and it must not point at your Let's Encrypt chain.
Why does my browser never ask me to choose a certificate?
nginx sends a list of acceptable issuers during the handshake, built from the file in ssl_client_certificate. A browser only offers certificates whose issuer appears on that list. No prompt therefore means the browser holds nothing from your CA: the import went into a different browser profile, or the certificate was signed by a different CA than the one installed on the server. Run openssl s_client -connect admin.example.com:443 and look for the acceptable client certificate CA names in the output to see which CA the server is actually asking for.
Can I require a client certificate on one URL only?
Not with ssl_verify_client on inside a location. The certificate is exchanged during the handshake, before nginx knows the request path, and the renegotiation that would work around that is gone from TLS 1.3 and forbidden in HTTP/2. Set ssl_verify_client optional; in the server block, then in each protected location test $ssl_client_verify and return 403 when it is not SUCCESS.
How do I revoke access for one person?
Revoke that certificate with openssl ca -revoke, regenerate the list with openssl ca -gencrl, copy it to the server, and reload nginx so it reads the new file. Everyone else is unaffected, which only works if each person holds their own certificate rather than a shared one. Watch the CRL's nextUpdate date, because an expired CRL fails verification for every client, not only for revoked ones.
Does mTLS replace a login page?
For reach, yes: without a certificate nothing gets to the application at all, so there is no form to attack and no password to guess. For identity inside the application, no. A certificate proves the caller holds a key file, so a stolen laptop is a valid user. Pass the CN upstream, keep whatever accounts and permissions the application already has, and treat the certificate as the gate in front of them.