What a client certificate proves
A client certificate flips normal TLS around: instead of only the server proving its identity to the browser, the client presents a certificate that the server verifies before it will answer. This is mutual TLS, or mTLS, and on an Ubuntu 24.04 LTS server nginx enforces it with two directives. This chapter builds a demo certificate authority, issues a client certificate, and proves a request without one is refused.
- mTLS adds a second check to the handshake: the client presents a certificate and the server verifies it against a CA the server trusts.
- You run your own CA for this, because the clients are yours: services, admins, or devices, not the public web.
- nginx enforces it with ssl_client_certificate, pointing at your CA, and ssl_verify_client on, which rejects any request without a valid client certificate.
- A request with a valid certificate gets 200; a request without one is refused at the TLS layer with a 400, before your app is ever reached.
So far the server has proved itself and certbot has done the issuing against a public authority. This chapter turns the trust around: you become the certificate authority for a small set of clients you control, using a throwaway demo CA that you remove again at the end.
Public TLS answers one question: is this server really boxlab.space? The client stays anonymous, which is right for a public website. But some endpoints should not be open to anyone who knows the URL: an internal admin panel, a service-to-service API, a webhook receiver. A password protects those, but a password can leak and travels inside the request.
mTLS moves the check to the connection itself. The client must hold a certificate signed by a CA the server trusts, or the TLS handshake never completes and no request is served. It is a strong gate, and because you control who gets a certificate, it is a gate you hold the only keys to.
Running your own CA
Public certificates come from Let's Encrypt because browsers must trust them without knowing you. Client certificates are the opposite: the only party that needs to trust them is your own server, so you are the certificate authority. Running a CA sounds heavy; for mTLS it is two openssl commands.
First, create the CA: a private key and a self-signed root certificate that will sign your clients.
openssl genrsa -out ca.key 2048
openssl req -x509 -new -nodes -key ca.key -sha256 -days 30 \
-subj "/CN=boxlab demo client CA" -out ca.crt
ca.crt is the public half nginx will trust; ca.key is the secret that signs clients and must stay protected. This one CA is the root of trust for every client certificate you issue. Guard the key like a password database, because anyone who holds it can mint a client that your server will accept.
Issuing a client certificate
With the CA in place, a client certificate is a key, a signing request, and the CA's signature on it:
openssl genrsa -out client.key 2048
openssl req -new -key client.key -subj "/CN=demo-client" -out client.csr
openssl x509 -req -in client.csr -CA ca.crt -CAkey ca.key \
-CAcreateserial -days 30 -sha256 -out client.crt
The client generates a key and a certificate signing request naming itself, demo-client. The CA signs that request to produce client.crt. Now the client holds client.key and client.crt, and because that certificate is signed by your CA, any server trusting the CA will accept it. You can confirm the signature before deploying:
openssl verify -CAfile ca.crt client.crt

A plain client.crt: OK means the CA's signature checks out. In production you issue one of these per client, keep the common name meaningful, and revoke a certificate by name if a client is compromised, without touching any of the others.
Confirm openssl verify -CAfile ca.crt client.crt prints client.crt: OK before you go near nginx. If it does not, the client certificate is not signed by the CA nginx will trust, so no server config will make it work. Verify the signature first, then wire up the server.
Enforcing it in nginx
nginx needs two directives inside the server block. ssl_client_certificate points at the CA certificate, so nginx knows which signatures to trust. ssl_verify_client on makes a valid client certificate mandatory:
server {
listen 443 ssl;
server_name secure.boxlab.space;
ssl_certificate /etc/letsencrypt/live/boxlab.space-0001/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/boxlab.space-0001/privkey.pem;
ssl_client_certificate /etc/nginx/mtls/ca.crt;
ssl_verify_client on;
root /var/www/boxlab;
}
The server still presents its own Let's Encrypt certificate to prove secure.boxlab.space to the client, exactly as before. What is new is the reverse direction: with ssl_verify_client on, nginx now demands a client certificate signed by the CA in ssl_client_certificate before it will serve anything. Test and reload with nginx -t and systemctl reload nginx, the same discipline as any config change.
Proving both sides
Now test it. A request that presents the client key and certificate should succeed:
curl -I --cert client.crt --key client.key https://secure.boxlab.space/

nginx checks the certificate against the CA, the verification passes, and you get HTTP/1.1 200 OK. The connection carried proof of who the client is, and the server answered. Now make the same request with no certificate:
curl https://secure.boxlab.space/

nginx refuses at the TLS layer and returns 400 Bad Request with the body No required SSL certificate was sent. The important part is where that rejection happens: before any request reaches your application. Your app code never runs for an unauthenticated client, so there is no login logic to get wrong and no request body to parse from a stranger. The gate sits at the connection.
This is why mTLS suits machine-to-machine traffic and internal tools. A public login page has to accept the request first and then decide; mTLS decides before the request is even framed. The cost is certificate distribution, which is real work, so you reserve it for clients you control rather than the open web.
Frequently asked questions
When should I use mTLS instead of a password or API key?
Use mTLS for clients you control: internal services, admin tools, device fleets, webhook senders. It rejects unauthenticated connections before your app runs. For the public web, where you cannot hand every visitor a certificate, stick with passwords or tokens.
Do I need a public CA for client certificates?
No. Only your own server has to trust client certificates, so you run your own CA with two openssl commands. A public authority like Let's Encrypt is for server certificates that anonymous browsers must trust without knowing you.
What does ssl_verify_client on actually reject?
Any connection that does not present a client certificate signed by the CA in ssl_client_certificate. nginx returns a 400 at the TLS layer, before your application is reached, so unauthenticated clients never touch your app code.
Can I make client certificates optional?
Yes. ssl_verify_client optional lets connections through without a certificate but records the result in $ssl_client_verify, so your app can allow both and treat verified clients differently. Use on when the endpoint should refuse anyone without a valid certificate outright.
What you can do now
You can stand up a small CA, issue a client certificate signed by it, and configure nginx to demand one with ssl_client_certificate and ssl_verify_client on. You have seen a valid client get a 200 and an unauthenticated one get a 400 at the TLS layer, before the app runs. That is a strong gate for internal and machine traffic.
You have now issued public and private certificates, automated their renewal, and enforced them in both directions. The last chapter is the one you reach for when something is wrong: reading a broken handshake, catching an expiry before it bites, and running a scanner over your TLS setup.