With regular HTTPS only the server proves its identity. Mutual TLS (mTLS) adds the other direction: the client must also present a certificate signed by a CA the server trusts, or the TLS handshake is rejected before any request reaches your application. This makes mTLS a good fit for service-to-service APIs, admin endpoints and IoT devices. In this tutorial you will create a small private CA with OpenSSL, issue a server and a client certificate, configure Nginx on Ubuntu 24.04 to require client certificates, and handle revocation and renewal.
Prerequisites
To follow this tutorial you need:
- A server running Ubuntu 24.04 LTS, for example a CubePath VPS, with Nginx installed (
sudo apt install nginx). - A non-root user with
sudoprivileges. - A hostname for the protected service. This guide uses
api.your_domain. It does not need a public DNS record for the local tests. - OpenSSL, which is preinstalled on Ubuntu 24.04 (version 3.0).
Step 1 - Creating a private certificate authority
The CA signs every certificate in this setup, and whoever holds its private key can issue certificates that your server will accept. Create it in a private working directory, and in production keep the CA key on a separate, well protected machine rather than on the web server.
mkdir -p ~/mtls && cd ~/mtls
chmod 700 ~/mtls
Generate a 4096-bit RSA key for the CA, protected by a passphrase. You will be asked for it every time you sign a certificate:
openssl genpkey -algorithm RSA -pkeyopt rsa_keygen_bits:4096 -aes256 -out ca.key
Create the self-signed CA certificate, valid for 10 years:
openssl req -x509 -new -key ca.key -sha256 -days 3650 -subj "/CN=Example Internal CA" -out ca.crt
Confirm that it is marked as a CA:
openssl x509 -in ca.crt -noout -subject -ext basicConstraints
subject=CN = Example Internal CA
X509v3 Basic Constraints: critical
CA:TRUE
Step 2 - Issuing the server certificate
The server certificate proves the identity of api.your_domain to clients. Modern clients check the Subject Alternative Name (SAN), not the Common Name, so the SAN is required.
Create an ECDSA key and a certificate signing request (CSR):
openssl genpkey -algorithm EC -pkeyopt ec_paramgen_curve:P-256 -out server.key
openssl req -new -key server.key -subj "/CN=api.your_domain" -out server.csr
Create an extensions file that restricts the certificate to server authentication:
nano server.ext
basicConstraints = CA:FALSE
keyUsage = critical, digitalSignature
extendedKeyUsage = serverAuth
subjectAltName = DNS:api.your_domain
Sign the CSR with the CA:
openssl x509 -req -in server.csr -CA ca.crt -CAkey ca.key -CAcreateserial -days 397 -sha256 -extfile server.ext -out server.crt
Verify the certificate against the CA:
openssl verify -CAfile ca.crt server.crt
server.crt: OK
TipIf the service is also public, you can use a Let's Encrypt certificate as the server certificate and keep the private CA only for client certificates. Clients then do not need your CA to trust the server.
Step 3 - Issuing a client certificate
Each client (a service, a device or a person) gets its own key and certificate, so you can identify and revoke clients individually. Name the certificate after the client, here billing-service:
openssl genpkey -algorithm EC -pkeyopt ec_paramgen_curve:P-256 -out billing-service.key
openssl req -new -key billing-service.key -subj "/CN=billing-service" -out billing-service.csr
Create the extensions file for client authentication:
nano client.ext
basicConstraints = CA:FALSE
keyUsage = critical, digitalSignature
extendedKeyUsage = clientAuth
Sign it:
openssl x509 -req -in billing-service.csr -CA ca.crt -CAkey ca.key -CAserial ca.srl -days 365 -sha256 -extfile client.ext -out billing-service.crt
Check the result and note the serial number, which you will need for revocation:
openssl x509 -in billing-service.crt -noout -subject -serial -enddate
subject=CN = billing-service
serial=5C1A9E3F0B7D4A21C6E08F13A9B2D7640E1F3A58
notAfter=Sep 25 10:40:12 2027 GMT
Deliver billing-service.crt and billing-service.key to the client over a secure channel, together with ca.crt so it can verify the server. Never send the CA key.
Step 4 - Installing the certificates on the server
Copy the server certificate, its key and the CA certificate to a directory that only root can read:
sudo install -d -m 0750 /etc/nginx/mtls
sudo install -m 0644 ca.crt server.crt /etc/nginx/mtls/
sudo install -m 0600 server.key /etc/nginx/mtls/
The CA private key (ca.key) stays in ~/mtls or, better, on another machine.
Step 5 - Requiring client certificates in Nginx
Create a site for the API:
sudo nano /etc/nginx/sites-available/api.conf
server {
listen 443 ssl;
listen [::]:443 ssl;
server_name api.your_domain;
ssl_certificate /etc/nginx/mtls/server.crt;
ssl_certificate_key /etc/nginx/mtls/server.key;
ssl_protocols TLSv1.2 TLSv1.3;
ssl_client_certificate /etc/nginx/mtls/ca.crt;
ssl_verify_client on;
ssl_verify_depth 2;
location / {
default_type text/plain;
return 200 "Hello $ssl_client_s_dn\n";
}
}
ssl_client_certificateis the CA that client certificates must be signed by.ssl_verify_client onrejects any request without a valid client certificate. Useoptionalif some locations must stay open, and check$ssl_client_verifyin the protected ones.ssl_verify_depth 2allows a client certificate signed directly by the root CA or by one intermediate.$ssl_client_s_dncontains the client certificate subject. Thereturnline is only for testing.
Enable the site and reload Nginx:
sudo ln -s /etc/nginx/sites-available/api.conf /etc/nginx/sites-enabled/
sudo nginx -t && sudo systemctl reload nginx
If UFW is active, allow HTTPS:
sudo ufw allow 443/tcp
Step 6 - Testing the mTLS connection
Run the tests from ~/mtls. The --resolve option sends api.your_domain to the local server without needing DNS.
Request the API with the client certificate:
curl --cacert ca.crt --cert billing-service.crt --key billing-service.key --resolve api.your_domain:443:127.0.0.1 https://api.your_domain/
Hello CN=billing-service
Now try without a client certificate:
curl --cacert ca.crt --resolve api.your_domain:443:127.0.0.1 https://api.your_domain/
<html>
<head><title>400 No required SSL certificate was sent</title></head>
...
A certificate signed by a different CA is also rejected, with 400 The SSL certificate error. The failure reason is recorded in /var/log/nginx/error.log.
Step 7 - Passing the client identity to the application
In a real setup Nginx proxies to an application, which usually wants to know which client is calling. Replace the test location with a proxy that forwards the verified identity in headers:
location / {
proxy_pass http://127.0.0.1:8080;
proxy_set_header Host $host;
proxy_set_header X-Client-Verify $ssl_client_verify;
proxy_set_header X-Client-DN $ssl_client_s_dn;
proxy_set_header X-Client-Serial $ssl_client_serial;
}
The application can then authorize each client by its DN, for example allowing CN=billing-service to call /invoices but not /admin. Make sure the application port is only reachable from Nginx, otherwise a client could send these headers directly.
Test and reload after the change:
sudo nginx -t && sudo systemctl reload nginx
Step 8 - Revoking a client certificate
If a client key leaks or a service is decommissioned, its certificate must stop working before it expires. For a small number of clients, the simplest approach is a denylist of serial numbers using Nginx's map. Add the map at the top of /etc/nginx/sites-available/api.conf, outside the server block:
map $ssl_client_serial $client_revoked {
default 0;
5C1A9E3F0B7D4A21C6E08F13A9B2D7640E1F3A58 1;
}
Use the serial from Step 3 without the serial= prefix. Inside the server block, reject revoked clients:
if ($client_revoked) {
return 403;
}
Reload Nginx and repeat the first test from Step 6. It now returns 403 Forbidden.
When you manage many clients, run a proper CA with openssl ca (or a tool such as step-ca) that tracks issued certificates and publishes a Certificate Revocation List, and point Nginx at it with the ssl_crl directive. Remember that Nginx only reads the CRL on reload, and rejects every client certificate once the CRL has expired.
Step 9 - Renewing certificates
Client certificates in this guide are valid for one year and the server certificate for 397 days. Check the expiry date of a certificate on the server with:
sudo openssl x509 -in /etc/nginx/mtls/server.crt -noout -enddate
To renew a client certificate, sign a new CSR (you can reuse the key or create a new one) as in Step 3 and deploy it to the client before the old one expires. Both certificates are valid at the same time, so there is no downtime. Short lifetimes (weeks instead of years) with automated renewal limit the damage of a stolen key.
To replace the CA itself, create the new CA, then put both CA certificates in ssl_client_certificate during the transition:
cat ca.crt new-ca.crt | sudo tee /etc/nginx/mtls/ca-bundle.crt > /dev/null
Point ssl_client_certificate to ca-bundle.crt, reload, reissue all client certificates from the new CA, and finally remove the old CA from the bundle.
HAProxy equivalent
If HAProxy terminates TLS instead of Nginx, the same policy goes on the bind line. HAProxy expects the certificate and key in one PEM file:
sudo install -d -m 0750 /etc/haproxy/mtls
cat server.crt server.key | sudo tee /etc/haproxy/mtls/server.pem > /dev/null
sudo chmod 600 /etc/haproxy/mtls/server.pem
sudo install -m 0644 ca.crt /etc/haproxy/mtls/
frontend api_in
bind :443 ssl crt /etc/haproxy/mtls/server.pem ca-file /etc/haproxy/mtls/ca.crt verify required
http-request set-header X-Client-DN %{+Q}[ssl_c_s_dn]
default_backend api_back
backend api_back
server app1 127.0.0.1:8080 check
verify required rejects the handshake when the client has no valid certificate, and crl-file on the same bind line loads a CRL.
Troubleshooting
400 No required SSL certificate was sent: the client did not present a certificate. Check the certificate and key paths in the client, and make sure no other proxy terminates TLS between the client and Nginx.400 The SSL certificate error: the client certificate was not signed by the CA inssl_client_certificate, has expired, lacks theclientAuthextended key usage (check withopenssl x509 -in client.crt -noout -ext extendedKeyUsage), or the chain is deeper thanssl_verify_depth. The exact reason is in/var/log/nginx/error.log.- curl:
SSL certificate problem: unable to get local issuer certificate: the client does not trust the server certificate. Pass the CA with--cacert ca.crt. - curl:
no alternative certificate subject name matches: the hostname in the URL is not in the server certificate's SAN. Reissue the server certificate with the correctsubjectAltName.
Conclusion
You built a private CA, issued server and client certificates, and configured Nginx so that only clients with a certificate from your CA can reach the API. You also passed the client identity to the application, revoked a certificate and planned renewals.
As next steps, automate certificate issuance and renewal with a dedicated CA such as step-ca, move the CA key off the server, and combine mTLS with authorization in your application based on the client certificate's subject.
