Client certificate authentication, also called mutual TLS (mTLS), makes the server check a certificate presented by the client during the TLS handshake. Requests without a certificate signed by your own certificate authority (CA) never reach the application, which makes it a strong way to protect admin panels, internal APIs and service-to-service traffic. In this tutorial you will create a small private CA with OpenSSL, issue a client certificate, configure Nginx on Ubuntu 24.04 to require it, and revoke a certificate with a certificate revocation list (CRL).
Prerequisites
To follow this guide you need:
- A server running Ubuntu 24.04 LTS, for example a CubePath VPS, with a non-root user that has
sudoprivileges. - Nginx installed (
sudo apt install nginx) and ports 80 and 443 open (sudo ufw allow 'Nginx Full'if UFW is enabled). - A domain such as
api.your_domainpointing to the server, with a normal server certificate from Let's Encrypt. If you do not have one yet, runsudo apt install certbot python3-certbot-nginxandsudo certbot certonly --nginx -d api.your_domain.
How the two certificates fit together
mTLS uses two independent trust chains:
| Certificate | Issued by | Verified by |
|---|---|---|
| Server certificate | A public CA (Let's Encrypt) | The client, using its normal trust store |
| Client certificate | Your private CA | Nginx, using ssl_client_certificate |
Your private CA is only used to sign client certificates, so it never needs to be trusted by browsers and you do not have to replace the Let's Encrypt certificate.
Step 1 - Creating the CA directory and configuration
Ideally the CA lives on a separate, well-protected machine and only the CA certificate and CRL are copied to the web server. For this tutorial you will create it in your home directory on the server. Create the directory layout that openssl ca expects:
mkdir -p ~/client-ca/{certs,crl,csr,newcerts,private}
cd ~/client-ca
chmod 700 private
touch index.txt
echo 1000 > serial
echo 1000 > crlnumber
index.txt is the CA database that records every issued and revoked certificate, and serial and crlnumber hold the next serial numbers.
Create the CA configuration file:
nano ~/client-ca/ca.cnf
[ ca ]
default_ca = client_ca
[ client_ca ]
dir = .
database = $dir/index.txt
new_certs_dir = $dir/newcerts
certificate = $dir/certs/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_client
unique_subject = no
copy_extensions = none
[ policy_client ]
organizationName = optional
commonName = supplied
[ client_cert ]
basicConstraints = critical, CA:FALSE
keyUsage = critical, digitalSignature
extendedKeyUsage = clientAuth
subjectKeyIdentifier = hash
authorityKeyIdentifier = keyid, issuer
Because dir is ., run every openssl ca command from inside ~/client-ca. Client certificates will be valid for one year (default_days) and each CRL for 30 days (default_crl_days).
Step 2 - Generating the CA key and certificate
Generate an ECDSA P-256 private key for the CA, encrypted with a passphrase. You will be asked for this passphrase whenever you sign or revoke a certificate:
cd ~/client-ca
openssl genpkey -algorithm EC -pkeyopt ec_paramgen_curve:P-256 -aes256 -out private/ca.key
chmod 400 private/ca.key
Create a self-signed CA certificate valid for 10 years:
openssl req -x509 -new -key private/ca.key -sha256 -days 3650 \
-subj "/O=Example/CN=Example Client CA" \
-addext "keyUsage=critical,keyCertSign,cRLSign" \
-out certs/ca.crt
Check that it is marked as a CA:
openssl x509 -in certs/ca.crt -noout -subject -ext basicConstraints,keyUsage
subject=O = Example, CN = Example Client CA
X509v3 Basic Constraints: critical
CA:TRUE
X509v3 Key Usage: critical
Certificate Sign, CRL Sign
Step 3 - Issuing a client certificate
Each person or service gets its own key and certificate, so you can revoke one without affecting the others. Create a key and a certificate signing request (CSR) for a user called alice:
openssl genpkey -algorithm EC -pkeyopt ec_paramgen_curve:P-256 -out private/alice.key
openssl req -new -key private/alice.key -subj "/O=Example/CN=alice" -out csr/alice.csr
Sign the CSR with the CA, applying the client_cert extensions. -batch skips the confirmation prompt; you will still be asked for the CA passphrase:
openssl ca -config ca.cnf -extensions client_cert -batch \
-in csr/alice.csr -out certs/alice.crt
Verify the certificate against the CA and confirm it is a client certificate:
openssl verify -CAfile certs/ca.crt certs/alice.crt
openssl x509 -in certs/alice.crt -noout -ext extendedKeyUsage
certs/alice.crt: OK
X509v3 Extended Key Usage:
TLS Web Client Authentication
Browsers and operating systems import client certificates as a PKCS#12 bundle that contains the key and certificate. Create one; you will be asked for an export password that protects the file:
openssl pkcs12 -export -inkey private/alice.key -in certs/alice.crt \
-certfile certs/ca.crt -name "alice" -out certs/alice.p12
Send alice.p12 to the user over a secure channel and share the export password separately.
Step 4 - Installing the CA certificate on the web server
Nginx only needs the CA certificate, never the CA key. Copy it to a dedicated directory:
sudo mkdir -p /etc/nginx/client-ca
sudo cp ~/client-ca/certs/ca.crt /etc/nginx/client-ca/ca.crt
Step 5 - Requiring client certificates in Nginx
Create a server block for api.your_domain. For now it returns the certificate subject so you can see what Nginx received; later you will proxy to your application instead.
sudo nano /etc/nginx/sites-available/api.your_domain
server {
listen 443 ssl http2;
listen [::]:443 ssl http2;
server_name api.your_domain;
ssl_certificate /etc/letsencrypt/live/api.your_domain/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/api.your_domain/privkey.pem;
ssl_protocols TLSv1.2 TLSv1.3;
ssl_client_certificate /etc/nginx/client-ca/ca.crt;
ssl_verify_client on;
ssl_verify_depth 1;
location / {
default_type text/plain;
return 200 "Hello $ssl_client_s_dn\n";
}
}
ssl_client_certificateis the CA that client certificates must chain to. Nginx also sends its name to clients so they know which certificate to offer.ssl_verify_client onrejects requests without a valid client certificate with HTTP 400.ssl_verify_depth 1is enough because the CA signs client certificates directly.
Enable the site, test and reload:
sudo ln -s /etc/nginx/sites-available/api.your_domain /etc/nginx/sites-enabled/
sudo nginx -t && sudo systemctl reload nginx
Step 6 - Testing with curl
A request without a certificate completes the TLS handshake but is rejected by Nginx:
curl -s https://api.your_domain/ | grep title
<head><title>400 No required SSL certificate was sent</title></head>
Now send Alice's certificate and key. Run this from ~/client-ca or copy the two files to the client machine:
curl --cert certs/alice.crt --key private/alice.key https://api.your_domain/
Hello CN=alice,O=Example
You can also test the PKCS#12 bundle, which is what a browser uses. Replace export_password with the password you chose:
curl --cert-type P12 --cert certs/alice.p12:export_password https://api.your_domain/
To use the certificate in a browser, import alice.p12 in the browser or operating system certificate manager. The browser asks which certificate to present the first time you open https://api.your_domain.
Step 7 - Passing the identity to your application
In a real setup Nginx proxies to an application and tells it who connected. Replace the location / block with a proxy, adjusting the upstream address to your application:
location / {
proxy_pass http://127.0.0.1:8080;
proxy_set_header Host $host;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_set_header X-SSL-Client-Verify $ssl_client_verify;
proxy_set_header X-SSL-Client-DN $ssl_client_s_dn;
proxy_set_header X-SSL-Client-Serial $ssl_client_serial;
}
The application can then authorize requests based on X-SSL-Client-DN. Make sure it only listens on 127.0.0.1 so that nobody can reach it directly and forge these headers.
If only part of the site needs a certificate, set ssl_verify_client optional; in the server block and check the result per location:
location /admin/ {
if ($ssl_client_verify != SUCCESS) {
return 403;
}
proxy_pass http://127.0.0.1:8080;
}
Reload Nginx after each change with sudo nginx -t && sudo systemctl reload nginx.
Step 8 - Revoking a certificate with a CRL
If a laptop is lost or an employee leaves, revoke the certificate and publish a new CRL. On the CA machine, from ~/client-ca:
cd ~/client-ca
openssl ca -config ca.cnf -revoke certs/alice.crt
openssl ca -config ca.cnf -gencrl -out crl/ca.crl
Check that the CRL lists the serial number:
openssl crl -in crl/ca.crl -noout -text | grep -A1 'Serial Number'
Serial Number: 1000
Revocation Date: Sep 25 10:12:43 2026 GMT
Copy the CRL to the web server and reference it in the server block, next to ssl_client_certificate:
sudo cp ~/client-ca/crl/ca.crl /etc/nginx/client-ca/ca.crl
ssl_crl /etc/nginx/client-ca/ca.crl;
Reload Nginx and repeat the curl request with Alice's certificate. It is now rejected:
<head><title>400 The SSL certificate error</title></head>
The Nginx error log shows the reason:
sudo tail -n 1 /var/log/nginx/error.log
... client SSL certificate verify error: (23:certificate revoked) while reading client request headers ...
Importanta CRL has an expiry date (
default_crl_days, 30 days here). Oncessl_crlis configured and the CRL expires, Nginx rejects every client certificate with "CRL has expired". Regenerate the CRL withopenssl ca -config ca.cnf -gencrl -out crl/ca.crl, copy it and reload Nginx well before the date shown byopenssl crl -in crl/ca.crl -noout -nextupdate, even when nothing new has been revoked.
Troubleshooting
Every request returns "400 No required SSL certificate was sent" even with --cert. The client certificate was not issued by the CA in ssl_client_certificate, so the client (curl or browser) did not offer it. Compare issuers with openssl x509 -in certs/alice.crt -noout -issuer and openssl x509 -in /etc/nginx/client-ca/ca.crt -noout -subject.
Error log shows "certificate has expired" or "unsupported certificate purpose". Check the dates with openssl x509 -in certs/alice.crt -noout -dates, and make sure the certificate was signed with -extensions client_cert so it carries TLS Web Client Authentication.
The .p12 file will not import on an older system. OpenSSL 3 encrypts PKCS#12 files with AES-256 by default, which some older operating systems cannot read. Re-export the bundle adding the -legacy option to openssl pkcs12 -export.
Conclusion
Nginx now accepts connections to api.your_domain only from clients holding a certificate signed by your private CA, passes their identity to the application and rejects revoked certificates through a CRL. As next steps, move the CA to an offline machine, schedule a reminder to renew the CRL, and consider a tool such as step-ca if you need to issue short-lived certificates to many services automatically.
