2026年11月12日
2026年11月12日
Nginx Client Certificate Authentication (mTLS): From CA Setup to Protecting wp-admin
This article is a localized edition of the Japanese original. Read the Japanese original
With normal HTTPS, the server presents a certificate and the client (a browser, for example) verifies it to confirm it is talking to the real server. With mTLS (mutual TLS, or client certificate authentication), the client also presents a certificate, and the server checks that the connection comes from a device or system it has allowed.
It is useful when you want to restrict who can call an API, or allow only specific devices into the WordPress admin. This article walks through setting up client certificate verification in Nginx, from creating the certificates to testing. The commands and configuration were tested with Nginx 1.30 and OpenSSL 3.5 (as of October 2026).
How it works and the files you need
mTLS uses two sets of certificates. Their roles are easy to mix up, so here they are side by side.
| File | Role | Where it lives |
|---|---|---|
| Server certificate and private key (e.g. fullchain.pem / privkey.pem) | Proves to clients that the server is genuine. Same as normal HTTPS | Server |
| Client CA certificate (e.g. client-ca.crt) | Certificate of the authority that issued the client certificates. Nginx uses it to verify client certificates | Server |
| Client CA private key (e.g. client-ca.key) | Key that signs client certificates | A secure place separate from the server |
| Client certificate and private key (e.g. client1.crt / client1.key) | The client proves its identity | Each client |
What you set in Nginx's ssl_client_certificate is not a client certificate but the client CA's certificate. Only clients holding a certificate signed by this CA can connect.
The server certificate can be an ordinary one from Let's Encrypt or similar. Client certificates go to a limited group, such as staff or business partners, so they are usually issued by a CA you create yourself (a private CA). If you put a public CA's certificate in ssl_client_certificate, anyone who bought a certificate from that CA would get through, so use a dedicated CA.
Step 1: Create the client CA
In a working directory, create the client CA's private key and certificate. The validity here is 10 years (3650 days).
openssl req -x509 -newkey rsa:3072 -sha256 -days 3650 -nodes \
-keyout client-ca.key -out client-ca.crt \
-subj "/CN=Example Client CA" \
-addext "basicConstraints=critical,CA:TRUE" \
-addext "keyUsage=critical,keyCertSign,cRLSign"
If client-ca.key leaks, anyone can create valid client certificates. Don't put it on the server; store it where only a few people have access. -nodes writes the key without a passphrase, so either protect the storage location or drop the option and set a passphrase.
Step 2: Issue client certificates
Create a key and a certificate signing request (CSR) for each client and sign it with the CA. extendedKeyUsage=clientAuth is the extension that marks the certificate as one for client authentication.
# Client key and CSR
openssl req -new -newkey rsa:2048 -nodes \
-keyout client1.key -out client1.csr \
-subj "/CN=client1/O=Example"
# Extensions to put in the certificate
cat > client.ext <<'EOF'
basicConstraints=CA:FALSE
keyUsage=critical,digitalSignature
extendedKeyUsage=clientAuth
EOF
# Sign with the CA (valid for 365 days)
openssl x509 -req -in client1.csr -CA client-ca.crt -CAkey client-ca.key \
-CAcreateserial -days 365 -sha256 -extfile client.ext -out client1.crt
Check the result. If you see client1.crt: OK and the issuer is Example Client CA, it worked.
openssl verify -CAfile client-ca.crt client1.crt
openssl x509 -in client1.crt -noout -subject -issuer -enddate -ext extendedKeyUsage
To load it into a browser, bundle the certificate and key in PKCS#12 format (.p12). You will be asked for an export password; give it to the person importing the file through a separate channel.
openssl pkcs12 -export -inkey client1.key -in client1.crt -certfile client-ca.crt -out client1.p12
If an older OS or application cannot read a .p12 made with OpenSSL 3, you can export it with the older encryption by adding -legacy.
Step 3: Require client certificates in Nginx
Copy the client CA certificate to the server. Do not copy the private key (client-ca.key).
sudo mkdir -p /etc/nginx/mtls
sudo cp client-ca.crt /etc/nginx/mtls/client-ca.crt
This example requires a client certificate for an entire API site.
server {
listen 443 ssl;
listen [::]:443 ssl;
http2 on;
server_name api.example.com;
# Server certificate (same as normal HTTPS)
ssl_certificate /etc/letsencrypt/live/api.example.com/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/api.example.com/privkey.pem;
# Client certificate verification
ssl_client_certificate /etc/nginx/mtls/client-ca.crt; # client CA certificate
ssl_verify_client on; # reject if missing or invalid
ssl_verify_depth 2;
location / {
proxy_pass http://127.0.0.1:8080;
# Pass the verification result and client details to the app
proxy_set_header X-SSL-Client-Verify $ssl_client_verify;
proxy_set_header X-SSL-Client-S-DN $ssl_client_s_dn;
proxy_set_header X-SSL-Client-Serial $ssl_client_serial;
}
}
What the main settings do:
ssl_client_certificate: the CA certificate(s) (PEM format) used to verify client certificates. The names of the CAs in this file are sent to clients during the handshake as the list of acceptable CAs.ssl_verify_client:onrequires a certificate and rejects connections that fail verification.optionalaccepts connections without a certificate and puts the result in the$ssl_client_verifyvariable.ssl_verify_depth: how deep to follow the certificate chain. The default is 1, which is enough for client certificates signed directly by the CA. Use 2 or more if there is an intermediate CA.$ssl_client_verify:SUCCESSwhen verification succeeds,NONEwhen there is no certificate, andFAILED:reasonwhen it fails.$ssl_client_s_dnholds the certificate's subject (e.g.O=Example,CN=client1).
Test the configuration and reload.
sudo nginx -t && sudo systemctl reload nginx
Step 4: Test with curl and openssl
Try both without and with a certificate.
# Without a certificate: 400
curl -s -o /dev/null -w '%{http_code}\n' https://api.example.com/
# With a client certificate: the app responds
curl -s --cert client1.crt --key client1.key https://api.example.com/
With ssl_verify_client on, Nginx returns 400 for connections without a certificate or with a certificate from another CA (the error page says "No required SSL certificate was sent" or "The SSL certificate error"). The reason for the failure is also written to the error log, so check /var/log/nginx/error.log if something doesn't work.
You can see which CA the server asks for with openssl s_client.
openssl s_client -connect api.example.com:443 -servername api.example.com \
-cert client1.crt -key client1.key </dev/null
If CN=Example Client CA appears under Acceptable client certificate CA names in the output, Nginx is verifying against that client CA.
Apply it only to the WordPress admin
This example limits only /wp-admin/ and wp-login.php, not the whole site, to devices with a client certificate. ssl_verify_client optional accepts connections with or without a certificate, and requests are rejected per URL based on the verification result.
Put the map blocks in the http context (in a file under /etc/nginx/conf.d/, outside any server block).
# 1 if verification succeeded
map $ssl_client_verify $mtls_ok {
SUCCESS 1;
default 0;
}
# Deny the admin and login screens without a certificate (except admin-ajax.php)
map "$mtls_ok:$uri" $mtls_deny {
default 0;
"~^0:/wp-admin/admin-ajax\.php$" 0;
"~^0:/wp-login\.php$" 1;
"~^0:/wp-admin(/|$)" 1;
}
server {
listen 443 ssl;
listen [::]:443 ssl;
http2 on;
server_name example.com;
ssl_certificate /etc/letsencrypt/live/example.com/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/example.com/privkey.pem;
ssl_client_certificate /etc/nginx/mtls/client-ca.crt;
ssl_verify_client optional;
ssl_verify_depth 2;
if ($mtls_deny) {
return 403;
}
# Your usual WordPress settings follow (root, location, PHP-FPM and so on)
}
Regular expressions in a map are checked in the order written, so the admin-ajax.php line comes before the /wp-admin line. admin-ajax.php is also called from the public side of the site (forms, filters and so on), so blocking it can break features for ordinary visitors.
With this configuration, requests without a certificate get 403 for /wp-admin/ and /wp-login.php, while the home page and admin-ajax.php work as usual. With a certificate, the admin is reachable too.
With optional, a browser that has a client certificate installed may show a certificate picker even on the public pages. If you can use a separate host name for administration, another option is to set on only for that host.
To use the result in PHP as well, add a line such as fastcgi_param SSL_CLIENT_VERIFY $ssl_client_verify; to the location that passes requests to PHP-FPM.
Revoking and renewing certificates
There are two main ways to invalidate a specific client certificate when someone leaves or a device is lost.
- Use a CRL (certificate revocation list): create a list of certificates revoked by the CA (a CRL in PEM format) and set it with
ssl_crl /etc/nginx/mtls/client-ca.crl;. A CRL has an expiry date, and once it expires verification fails and nobody can connect, so you need to regenerate and reload it regularly. - Deny by serial number: with only a few clients, you can match
$ssl_client_serial(the certificate's serial number) in amapand return 403. Get the serial number withopenssl x509 -in client1.crt -noout -serial.
Client certificates expire, so issue and distribute new ones before the expiry date. If you recreate the client CA certificate, all client certificates have to be reissued.
Things to watch
- Where TLS terminates: if a load balancer or CDN (such as the Cloudflare proxy) terminates TLS, the client's certificate never reaches Nginx. In that case, configure mTLS where TLS terminates.
- Handling private keys: manage the client CA's private key and the passwords of the .p12 files you hand out like any other secret.
- Roll it out in stages: switching straight to
onlocks out every user who has not installed a certificate yet. Start withoptional, log$ssl_client_verify, and switch to rejecting only after confirming everyone showsSUCCESS.
Summary
- For mTLS, set the client CA's certificate in Nginx's
ssl_client_certificateand verify client certificates withssl_verify_client - Issue client certificates from a dedicated private CA with
extendedKeyUsage=clientAuth. Keep the CA's private key off the server onrequires it for the whole site; combiningoptionalwithmapapplies it only to the WordPress admin- Use curl's
--certand--keyandopenssl s_clientto confirm requests without a certificate are rejected and those with one get through - Handle revocation with a CRL or serial numbers, and keep track of certificate and CRL expiry dates
Related articles
- How to set up an SSL certificate for Nginx with Certbot
- How to check certificate details with openssl
- How to set up Basic authentication in Nginx
Questions about WordPress? The publisher of this site is based in Japan and replies in Japanese. Contact (Japanese)