SSD Nodes Learn 🎉 VPS from $5.50/mo
How to do am Matt ConnorBy Matt Connor

mTLS for nginx: Client Certificate Setup Guide

Lock nginx admin panels with mTLS: use openssl to create a private CA, issue client certificates, and reject requests without a valid certificate.

Wetín mTLS dey do

Mutual TLS, wey dem usually write as mTLS, make nginx ask every client for certificate. E go refuse the request if certificate no dey, or if certificate authority (CA) wey you control no issue am. This check dey happen inside TLS (transport layer security) handshake. So caller wey no get valid client certificate no go reach your application at all. Na this be the main benefit: admin panel or metrics endpoint fit dey for public internet without login page and without anything wey bot fit guess.

The setup small. One private CA wey you make with openssl, one certificate for each person, and three directives inside nginx server block. The work wey go decide whether this setup go last for one year na operational work. So most of this guide dey cover certificate lifetimes, revocation, certificates for each person, and wetin to do when client request dey refused but nobody fit see the reason.

Chain two dey, no be one

Two certificate chain dey for mTLS setup, and dem no get anything to do with each other. To join dem together na the first mistake wey almost everybody dey make.

The first chain na the server own. Your VPS go present certificate for admin.example.com wey public CA like Let's Encrypt issue, and browser go check am against root store wey come with operating system. Nothing about mTLS dey change that part. If certbot issue that certificate for you today, keep am exactly as e be: see how to issue Let's Encrypt certificate for nginx with certbot.

The second chain na the client own. You go create small CA wey belong to you, sign one certificate for each person wey need access, then tell nginx to trust that CA and only that CA when e dey check clients. No public root store know your CA, and none need to know am. The only party wey need trust am na nginx, through the ssl_client_certificate file.

So ssl_client_certificate no dey affect the certificate wey nginx present, and the Let's Encrypt chain no dey affect which clients fit enter. Pointing ssl_client_certificate to fullchain.pem no dey do wetin e look like e dey do: that directive dey name the issuers wey client certificate fit come from, and na the other end of the connection be that. To make the server trust your CA for e own outbound work na separate task. How to add your own CA to the Ubuntu trust store cover am, and system trust store no be the one nginx dey read when e dey verify client.

Build your own client CA with openssl

Build the CA for another place wey no be the web server. nginx only need the CA public certificate. The CA private key dey sign new client certificates, so if you leave am for a box wey dey face internet, one breach fit give attacker power to create valid clients for himself or herself anytime.

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

index.txt, serial and crlnumber na the CA database. openssl ca no go run without dem. Dem dey also make revocation possible later, because revocation list dey name serial numbers. So the CA must remember which serial number e give to who.

Write ~/client-ca/openssl.cnf. Set dir to the real path of that directory, because openssl ca no dey 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,issuer

Now make 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 passphrase for the CA key, so every signing run go ask for am. Na the purpose of this command be that. Check wetin you make:

openssl x509 -in ca.crt -noout -subject -dates -ext basicConstraints

The subject suppose be your CA, and the validity suppose last ten years. The extension line suppose read CA:TRUE, pathlen:0. pathlen:0 mean say this CA fit sign end certificates, but e no fit sign another CA. This keeps the chain exactly one level deep and lets you leave ssl_verify_depth alone.

Issue client certificate give each person

One certificate for each person. Never use one certificate for whole team, because you no fit revoke shared certificate without locking everybody out, and e no tell you who make the call.

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

openssl ca go print the certificate wey e wan sign, ask for the CA passphrase, ask twice for confirmation, then append one line to index.txt. Add -batch when you dey script am. The client_ext section important because of one line inside am: extendedKeyUsage = clientAuth. Certificate wey get extended key usage listing only serverAuth go fail client authentication, so state the purpose instead of assuming say e go work.

Verify the pair against the CA before you give anybody:

openssl verify -CAfile ca.crt certs/alice.crt

That go print certs/alice.crt: OK. Any other output mean say the certificate and the CA no match, and no nginx configuration fit fix am.

Put the key and certificate inside one file wey browser fit import:

openssl pkcs12 -export -inkey private/alice.key -in certs/alice.crt \
  -name "alice at example ops" -out alice.p12

The export go ask for password, and this password protect the file while dem dey transfer am. Send the file and the password through different channels, and give people the .p12 instead of bare .key. You fit add -certfile ca.crt to include the CA inside the bundle, but nginx no need am: nginx already get ca.crt, so certificate wey that CA sign directly go verify by itself.

OpenSSL 3, wey Ubuntu 24.04 release, dey write PKCS#12 files with current encryption, and browsers plus operating systems wey people dey use as of August 2026 fit read dem. If old importer reject the file, export am again with -legacy added. This one go fall back to the older algorithms wey the importer expect. Read the message wey the importer show before you use that flag.

Configure nginx with ssl_client_certificate and ssl_verify_client

Copy the CA certificate, and na only the CA certificate, go 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 correct for here. CA certificate na public information. CA key dey your workstation.

Then add three directives to the server block wey already dey terminate 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 na the nginx default, and e mean say client certificate must get signature from the CA wey dey that file directly. Raise am only if you add intermediate. nginx also sends the subject names from ssl_client_certificate go the client during handshake. Na this make browser know which of its certificates e suppose offer. Na why you use ssl_client_certificate instead of ssl_trusted_certificate. Dem both verify the same way, but ssl_trusted_certificate no send any list.

Ubuntu 24.04 ships nginx 1.24, where HTTP/2 dey for the listen line as listen 443 ssl http2;. For nginx 1.25.1 and later, that form don deprecated, and HTTP/2 get its own directive, http2 on;. Neither option change 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 no carry certificate, so e suppose return 400 Bad Request with body No required SSL certificate was sent. Na nginx dey reject am for its own gate. This mean say configuration don become active and application never receive request. Now try am the correct way:

curl --cert certs/alice.crt --key private/alice.key https://admin.example.com/

That one suppose return anything wey your application dey serve.

Why the gate dey inside the server block

nginx dey exchange the certificate during the TLS handshake, before e read any request line. So for that moment, nginx no know which location the request go enter. If you put ssl_verify_client on; inside location, you dey ask the client to renegotiate for the middle of the connection. TLS 1.3 don remove renegotiation, and HTTP/2 no allow am, so for current stack that pattern go fail instead of prompting.

Na you go handle the scope. Ask for certificate for server level, then decide am 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 fit contain SUCCESS, or NONE when client no send anything, or FAILED: followed by reason. With optional, nginx go request certificate and verify am only if one arrive. Na this one make the public /healthz path above work while /metrics remain shut. If certificate dey send but e fail verification, nginx still go reject am for that point. If you prefer inspect failing certificate by yourself, na optional_no_ca be that. Then your own test must treat every value wey no be SUCCESS as refusal.

nginx get non-standard status codes for this matter, and error_page fit catch dem so refused visitor go see explanation instead of 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 mean say client certificate fail verification. 496 mean say client no present certificate. Make that page plain text, because the person wey dey read am no get session and no account.

How I fit install client certificate for browser?

Firefox get im own certificate store: go Settings, then Privacy and Security, then View Certificates, then the Your Certificates tab, then Import. Pick the .p12 and type the password.

Chrome and Edge dey use operating system store for Windows and macOS. So, when you open the .p12 file, system import wizard go start. For Linux, Chrome dey read separate NSS (network security services) database for your home directory. The command line tool na the reliable way:

sudo apt install -y libnss3-tools
pk12util -d sql:$HOME/.pki/nssdb -i alice.p12

Load the site afterwards, and browser go ask which certificate e suppose send. Chrome go remember that choice for the rest of the browser session. Restart the browser when you want make e ask again. The certificate dey inside one browser profile for one machine. So, certificate wey you import into Firefox no dey visible to Chrome, and both no dey visible to your phone.

Testing with curl --cert

Debug with curl, because e go report wetin e do.

curl -v --cert certs/alice.crt --key private/alice.key https://admin.example.com/

You fit join the certificate and the key together inside one PEM file, then pass am as --cert alice.pem. If the key get passphrase, curl go ask you for am. E still accept --cert alice.pem:passphrase, but that one go enter your shell history, so make you use the prompt.

You suppose run these two checks before you blame nginx. First, the certificate and the key must match:

openssl x509 -noout -pubkey -in certs/alice.crt | openssl sha256
openssl pkey -pubout -in private/alice.key | openssl sha256

Two identical hashes mean say the files belong together. Two different hashes mean say you mix up two people files, and no client go tell you that na the cause.

Second, the server suppose dey ask for your CA:

openssl s_client -connect admin.example.com:443 -servername admin.example.com </dev/null

Look for the Acceptable client certificate CA names block inside the output, and check say your CA subject dey inside am. If the block no dey at all, nginx no dey request certificate for the server block wey answer the request. That mean your directives enter another server block, often the default server.

Client CN go application

Certificate dey show who call, but application wey dey behind proxy no fit see TLS layer, so nginx gats pass the name go am.

map $ssl_client_s_dn $client_cn {
    default              "";
    "~,?CN=(?<cn>[^,]+)" $cn;
}

$ssl_client_s_dn dey hold subject distinguished name for RFC 2253 format, wey look like CN=alice,O=Example Ops. The map dey bring CN field enter $client_cn. Keep the CN as plain username, because comma inside CN dey escaped for that format, and the small regular expression above no handle that 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 dey replace any header with that name wey caller send, so nobody fit forge X-Client-Cert-CN through this location. Two conditions dey make this remain true. nginx dey inherit proxy_set_header from the outer level only when the inner level no define any of its own. So, another location wey get one proxy_set_header line go silently lose every header wey dem set above am, including this one. Also, application gats no be reachable except through nginx. That means bind am to 127.0.0.1 instead of 0.0.0.0, because app wey dey public port go read the forged header directly from internet. This nginx reverse proxy configuration get explanation line by line cover the proxy side of this. If application want the complete certificate instead of just a name, $ssl_client_escaped_cert carry am URL-encoded and safe inside a header.

How I go revoke one client certificate?

Person fit leave, or laptop fit go missing. You go revoke only that certificate, and everybody else go continue to work. Na this be the main reason why you issue one certificate for each person.

cd ~/client-ca
openssl ca -config openssl.cnf -revoke certs/alice.crt
openssl ca -config openssl.cnf -gencrl -out crl.pem

The first command go change the line for that serial inside index.txt from V to R. The second command go write certificate revocation list (CRL), wey be signed file wey list revoked serial numbers. Copy the file go the server and point nginx to am 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'

Na here the trap wey fit block everybody dey. CRL get nextUpdate date, and default_crl_days set am to 30 for the config above. After that date pass, OpenSSL go treat the list as stale and verification go fail for every client certificate with CRL has expired, no be only the revoked one. nginx go read the file when e load configuration, so new CRL wey dey disk no go change anything until you reload nginx. Regenerate the CRL and reload nginx on schedule wey dey well inside the window, like every week for 30 days, then check the dates before you copy the file:

openssl crl -in crl.pem -noout -lastupdate -nextupdate

If na only few users, smaller option dey. The CA na your own, so nginx fit reject one serial directly and skip all the CRL work:

map $ssl_client_serial $revoked {
    default 0;
    "1002"  1;
}

Use am together with if ($revoked) { return 403; } inside the location. E no get expiry date wey you fit forget. E also no dey travel, so any other system wey trust your CA no go know about am. If na one nginx wey dey in front of one application, this na the straightforward simple answer. Move to CRL when you get more than one gate.

How long client certificates suppose last?

Give client certificates one year, or less if you fit handle the work to issue dem again. Expiry na the quiet failure here, because nothing dey warn the holder ahead of time. One morning dem go open the panel, nginx refuse the connection, and the browser describe the refusal with its own words. Most times, e no go even mention say the certificate don expire. Keep the CA for ten years and write the expiry date for place wey you go really see am. When the CA certificate expire, every certificate under am go stop verifying that same day.

Two commands go help you stay ahead of this:

openssl x509 -in certs/alice.crt -noout -subject -serial -enddate
awk -F'\t' '{print $1, $2, $4}' ~/client-ca/index.txt

The first column for index.txt na the status: V for valid, R for revoked, E for expired. The second column na the expiry for YYMMDDHHMMSSZ format, and the fourth one na the serial. That file na your only record of who get which certificate, so back am up together with the CA key and treat both as secrets.

Renewal na new certificate, no be extension. Generate fresh key and CSR (certificate signing request), sign am, give am to the person, then revoke the old one after the person confirm say the new one dey work.

Wetin mTLS dey protect against, and wetin e no protect against

Wetin e remove na unauthenticated access. Scanner wey find your hostname go get refusal during handshake, so e no go ever send HTTP request, see login form, or try stolen password against one. Credential stuffing no get anything to stuff. Vulnerability for application login flow no dey reachable by anybody wey no get certificate. E still remove shared secret wey people dey paste for chat, because private key na file wey no easy copy by mistake.

Wetin e no do na protect against compromised client. Malware for laptop get the key file, and e get the passphrase immediately the owner type am. To server, that attacker look exactly like legitimate user, because certificate prove say person get the file, no be say person dey there. The .p12 password and full disk encryption still important.

E no be authorization too. Every valid certificate fit reach everything wey that server block dey serve unless you check $client_cn and act based on the value. By default, two certificate holders get the same access.

E only protect the path wey pass through nginx. If application still dey listen on public port, mTLS for front no solve anything: bind the app to 127.0.0.1 and keep firewall closed for the port. The other way enter the same box na SSH, and e need the same attention. We cover am for how to harden SSH access for your VPS.

One last limit dey, and e go cause problem the day you switch am on. Anything wey no fit present certificate go stop working: uptime monitor, webhook from payment provider, RSS reader, or mobile app wey no get certificate store wey you fit access. Decide about dem before you set ssl_verify_client on, because the failure total, and for their side, e silent.

When client refuse, read wetin the client report

The message wey refused client show dey depend on the browser, the curl version, and the TLS library underneath. So read wetin your own client print instead of comparing am with message wey person write somewhere else. The useful detail dey for the server.

sudo tail -n 50 /var/log/nginx/error.log

Rejected certificate go leave one line wey contain client SSL certificate verify error followed by the reason OpenSSL give. Na this reason you need act on. Usually, na one of some few things. The certificate come from different CA from the file wey ssl_client_certificate name. The certificate don pass its validity dates. The CRL for the server don pass its nextUpdate, so e now fail every client instead of only one.

When browser no offer certificate at all, the problem dey before verification. nginx dey send the acceptable issuer names during handshake, and browser no find anything for its store wey match. So e get nothing to offer you. Import the .p12 again into the profile wey you really dey use browse with.

One more case dey worth mention. If you test with one self-signed client certificate instead of certificate wey your CA sign, verification no fit pass, because nginx dey check the signature against the CA file and self-signed certificate no dey inside am. The way to create the certificate na the same as for how to generate self-signed certificate for Ubuntu. mTLS just need the extra step where your CA sign am.

FAQ

I still need a Let's Encrypt certificate if I use mTLS?

Yes. The two certificates no get connection with each other. Your server go present its own certificate so browser go trust the hostname, and that certificate still need come from a CA wey browser already know. Your client CA na separate private chain, and na only to check who dey connect. Setting ssl_client_certificate no change anything about the certificate nginx dey present, and e must not point to your Let's Encrypt chain.

Why my browser never ask me to choose certificate?

nginx dey send list of acceptable issuers during handshake. E build the list from the file for ssl_client_certificate. Browser go only offer certificates wey their issuer dey on that list. If no prompt show, e mean browser no get anything from your CA. The import fit don enter another browser profile, or the certificate fit come from another CA different from the one installed for server. Run openssl s_client -connect admin.example.com:443 and check the acceptable client certificate CA names for the output, so you go see which CA the server dey actually ask for.

I fit require client certificate for only one URL?

No, not with ssl_verify_client on inside location. The certificate dey exchange during handshake, before nginx know the request path. The renegotiation wey fit solve this no dey available for TLS 1.3, and HTTP/2 no allow am. Set ssl_verify_client optional; for the server block. Then, for each protected location, test $ssl_client_verify and return 403 when e no be SUCCESS.

How I go revoke access for one person?

Revoke that certificate with openssl ca -revoke. Regenerate the list with openssl ca -gencrl, copy am go the server, then reload nginx so e go read the new file. Other people no go affect, but this only work if each person get their own certificate instead of one shared certificate. Monitor the CRL's nextUpdate date, because expired CRL go make verification fail for every client, not only clients wey dem revoke.

mTLS fit replace login page?

For access, yes. Without certificate, nothing fit reach the application at all, so no form dey available to attack and nobody fit guess password. For identity inside the application, no. Certificate only prove say the caller hold a key file, so stolen laptop fit still be valid user. Pass the CN upstream, keep the accounts and permissions wey the application already get, and treat the certificate as the gate before dem.

#tls#mtls#nginx#openssl#access-control