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: on requires a certificate and rejects connections that fail verification. optional accepts connections without a certificate and puts the result in the $ssl_client_verify variable.
  • 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: SUCCESS when verification succeeds, NONE when there is no certificate, and FAILED:reason when it fails. $ssl_client_s_dn holds 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 a map and return 403. Get the serial number with openssl 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 on locks out every user who has not installed a certificate yet. Start with optional, log $ssl_client_verify, and switch to rejecting only after confirming everyone shows SUCCESS.

Summary

  • For mTLS, set the client CA's certificate in Nginx's ssl_client_certificate and verify client certificates with ssl_verify_client
  • Issue client certificates from a dedicated private CA with extendedKeyUsage=clientAuth. Keep the CA's private key off the server
  • on requires it for the whole site; combining optional with map applies it only to the WordPress admin
  • Use curl's --cert and --key and openssl s_client to 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

Questions about WordPress? The publisher of this site is based in Japan and replies in Japanese. Contact (Japanese)

お気軽にご相談ください

お見積りへ お問い合わせへ
▲